Files
picoclaw/pkg/evolution/store_test.go
T
lxowalle b3a7b7ad64 feat: agent self evolution (#2847)
* feat: add agent self-evolution

* fix ci

* delete unused doc

* fix lint

* fix evolution review issues
2026-05-11 16:13:27 +08:00

439 lines
13 KiB
Go

package evolution_test
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func TestStore_AppendLearningRecordsPersistsCaseAndRule(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
records := []evolution.LearningRecord{
{
ID: "case-1",
Kind: evolution.RecordKindCase,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "weather task completed",
Status: evolution.RecordStatus("new"),
},
{
ID: "rule-1",
Kind: evolution.RecordKindRule,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "prefer native-name weather path",
Status: evolution.RecordStatus("ready"),
},
}
if err := store.AppendLearningRecords(records); err != nil {
t.Fatalf("AppendLearningRecords: %v", err)
}
loaded, err := store.LoadLearningRecords()
if err != nil {
t.Fatalf("LoadLearningRecords: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("len(loaded) = %d, want 2", len(loaded))
}
if loaded[1].Kind != evolution.RecordKindRule {
t.Fatalf("loaded[1].Kind = %q, want %q", loaded[1].Kind, evolution.RecordKindRule)
}
if _, statErr := os.Stat(paths.LearningRecords); !os.IsNotExist(statErr) {
t.Fatalf("legacy learning records file should not be written, stat err = %v", statErr)
}
if _, statErr := os.Stat(paths.TaskRecords); statErr != nil {
t.Fatalf("task records file should exist: %v", statErr)
}
if _, statErr := os.Stat(paths.PatternRecords); statErr != nil {
t.Fatalf("pattern records file should exist: %v", statErr)
}
}
func TestStore_LoadTaskRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
legacy := evolution.LearningRecord{
ID: "legacy-task",
Kind: evolution.RecordKindTask,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "legacy task",
Status: evolution.RecordStatus("new"),
}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatalf("Marshal legacy: %v", err)
}
if mkdirErr := os.MkdirAll(paths.RootDir, 0o755); mkdirErr != nil {
t.Fatalf("MkdirAll: %v", mkdirErr)
}
if writeErr := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); writeErr != nil {
t.Fatalf("WriteFile legacy: %v", writeErr)
}
current := evolution.LearningRecord{
ID: "current-task",
Kind: evolution.RecordKindTask,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "current task",
Status: evolution.RecordStatus("new"),
}
if appendErr := store.AppendTaskRecord(context.Background(), current); appendErr != nil {
t.Fatalf("AppendTaskRecord: %v", appendErr)
}
records, err := store.LoadTaskRecords()
if err != nil {
t.Fatalf("LoadTaskRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("len(records) = %d, want 2: %+v", len(records), records)
}
ids := records[0].ID + "," + records[1].ID
if !strings.Contains(ids, "legacy-task") || !strings.Contains(ids, "current-task") {
t.Fatalf("records should include legacy and current task IDs, got %q", ids)
}
}
func TestStore_LoadPatternRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
legacy := evolution.LearningRecord{
ID: "legacy-pattern",
Kind: evolution.RecordKindPattern,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "legacy pattern",
Status: evolution.RecordStatus("ready"),
}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatalf("Marshal legacy: %v", err)
}
if mkdirErr := os.MkdirAll(paths.RootDir, 0o755); mkdirErr != nil {
t.Fatalf("MkdirAll: %v", mkdirErr)
}
if writeErr := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); writeErr != nil {
t.Fatalf("WriteFile legacy: %v", writeErr)
}
current := evolution.LearningRecord{
ID: "current-pattern",
Kind: evolution.RecordKindPattern,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "current pattern",
Status: evolution.RecordStatus("ready"),
}
if appendErr := store.AppendPatternRecords([]evolution.LearningRecord{current}); appendErr != nil {
t.Fatalf("AppendPatternRecords: %v", appendErr)
}
records, err := store.LoadPatternRecords()
if err != nil {
t.Fatalf("LoadPatternRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("len(records) = %d, want 2: %+v", len(records), records)
}
ids := records[0].ID + "," + records[1].ID
if !strings.Contains(ids, "legacy-pattern") || !strings.Contains(ids, "current-pattern") {
t.Fatalf("records should include legacy and current pattern IDs, got %q", ids)
}
}
func TestStore_MarkTaskRecordsClusteredPreservesNewerAppendedRecords(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
first := evolution.LearningRecord{
ID: "task-1",
Kind: evolution.RecordKindTask,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "first task",
Status: evolution.RecordStatus("new"),
}
if err := store.AppendTaskRecord(context.Background(), first); err != nil {
t.Fatalf("AppendTaskRecord(first): %v", err)
}
if _, err := store.LoadTaskRecords(); err != nil {
t.Fatalf("LoadTaskRecords snapshot: %v", err)
}
second := evolution.LearningRecord{
ID: "task-2",
Kind: evolution.RecordKindTask,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "second task",
Status: evolution.RecordStatus("new"),
}
if err := store.AppendTaskRecord(context.Background(), second); err != nil {
t.Fatalf("AppendTaskRecord(second): %v", err)
}
if err := store.MarkTaskRecordsClustered([]string{"task-1"}); err != nil {
t.Fatalf("MarkTaskRecordsClustered: %v", err)
}
records, err := store.LoadTaskRecords()
if err != nil {
t.Fatalf("LoadTaskRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("len(records) = %d, want 2: %+v", len(records), records)
}
statusByID := map[string]evolution.RecordStatus{}
for _, record := range records {
statusByID[record.ID] = record.Status
}
if statusByID["task-1"] != evolution.RecordStatus("clustered") {
t.Fatalf("task-1 status = %q, want clustered", statusByID["task-1"])
}
if statusByID["task-2"] != evolution.RecordStatus("new") {
t.Fatalf("task-2 status = %q, want new", statusByID["task-2"])
}
}
func TestStore_MergeKeepsSameRecordIDAcrossWorkspaces(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths("workspace-a", root))
records := []evolution.LearningRecord{
{
ID: "main-turn-1",
Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-a",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "workspace a task",
Status: evolution.RecordStatus("new"),
},
{
ID: "main-turn-1",
Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-b",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "workspace b task",
Status: evolution.RecordStatus("new"),
},
}
if err := store.AppendTaskRecords(context.Background(), records); err != nil {
t.Fatalf("AppendTaskRecords: %v", err)
}
loaded, err := store.LoadTaskRecords()
if err != nil {
t.Fatalf("LoadTaskRecords: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("len(loaded) = %d, want 2: %+v", len(loaded), loaded)
}
if markErr := store.MarkTaskRecordsClustered([]string{"main-turn-1"}); markErr != nil {
t.Fatalf("MarkTaskRecordsClustered: %v", markErr)
}
loaded, err = store.LoadTaskRecords()
if err != nil {
t.Fatalf("LoadTaskRecords after clustered: %v", err)
}
statusByWorkspace := map[string]evolution.RecordStatus{}
for _, record := range loaded {
statusByWorkspace[record.WorkspaceID] = record.Status
}
if statusByWorkspace["workspace-a"] != evolution.RecordStatus("clustered") {
t.Fatalf("workspace-a status = %q, want clustered", statusByWorkspace["workspace-a"])
}
if statusByWorkspace["workspace-b"] != evolution.RecordStatus("new") {
t.Fatalf("workspace-b status = %q, want new", statusByWorkspace["workspace-b"])
}
}
func TestStore_MergePatternRecordsPreservesNewerWorkspaceRecords(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
first := evolution.LearningRecord{
ID: "pattern-a",
Kind: evolution.RecordKindPattern,
WorkspaceID: "workspace-a",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "workspace a pattern",
Status: evolution.RecordStatus("ready"),
}
if err := store.SavePatternRecords([]evolution.LearningRecord{first}); err != nil {
t.Fatalf("SavePatternRecords(first): %v", err)
}
if _, err := store.LoadPatternRecords(); err != nil {
t.Fatalf("LoadPatternRecords snapshot: %v", err)
}
second := evolution.LearningRecord{
ID: "pattern-b",
Kind: evolution.RecordKindPattern,
WorkspaceID: "workspace-b",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "workspace b pattern",
Status: evolution.RecordStatus("ready"),
}
if err := store.MergePatternRecords([]evolution.LearningRecord{second}); err != nil {
t.Fatalf("MergePatternRecords: %v", err)
}
records, err := store.LoadPatternRecords()
if err != nil {
t.Fatalf("LoadPatternRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("len(records) = %d, want 2: %+v", len(records), records)
}
ids := records[0].ID + "," + records[1].ID
if !strings.Contains(ids, "pattern-a") || !strings.Contains(ids, "pattern-b") {
t.Fatalf("records should include both workspace patterns, got %q", ids)
}
}
func TestStore_SaveDraftsOverwritesByID(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
first := evolution.SkillDraft{
ID: "draft-1",
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
SourceRecordID: "rule-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "prefer native-name path first",
BodyOrPatch: "## Start Here",
Status: evolution.DraftStatusCandidate,
}
second := first
second.HumanSummary = "updated summary"
if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil {
t.Fatalf("SaveDrafts(first): %v", err)
}
if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil {
t.Fatalf("SaveDrafts(second): %v", err)
}
loaded, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("len(loaded) = %d, want 1", len(loaded))
}
if loaded[0].HumanSummary != "updated summary" {
t.Fatalf("HumanSummary = %q, want %q", loaded[0].HumanSummary, "updated summary")
}
}
func TestStore_SaveDraftsKeepsSameIDDifferentWorkspace(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
first := evolution.SkillDraft{
ID: "draft-1",
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
SourceRecordID: "rule-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "workspace one",
BodyOrPatch: "## Start Here",
Status: evolution.DraftStatusCandidate,
}
second := first
second.WorkspaceID = "ws-2"
second.HumanSummary = "workspace two"
if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil {
t.Fatalf("SaveDrafts(first): %v", err)
}
if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil {
t.Fatalf("SaveDrafts(second): %v", err)
}
loaded, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("len(loaded) = %d, want 2", len(loaded))
}
if loaded[0].WorkspaceID == loaded[1].WorkspaceID {
t.Fatalf("loaded drafts should keep distinct workspace IDs: %+v", loaded)
}
}
func TestStore_LoadLearningRecordsIgnoresTruncatedTrailingLine(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
record := evolution.LearningRecord{
ID: "case-1",
Kind: evolution.RecordKindCase,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "weather task completed",
Status: evolution.RecordStatus("new"),
}
if err := store.AppendLearningRecords([]evolution.LearningRecord{record}); err != nil {
t.Fatalf("AppendLearningRecords: %v", err)
}
f, err := os.OpenFile(paths.TaskRecords, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
t.Fatalf("OpenFile: %v", err)
}
if _, writeErr := f.WriteString("{\"id\":\"broken\""); writeErr != nil {
f.Close()
t.Fatalf("WriteString: %v", writeErr)
}
if closeErr := f.Close(); closeErr != nil {
t.Fatalf("Close: %v", closeErr)
}
loaded, err := store.LoadLearningRecords()
if err != nil {
t.Fatalf("LoadLearningRecords: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("len(loaded) = %d, want 1", len(loaded))
}
if loaded[0].ID != "case-1" {
t.Fatalf("loaded[0].ID = %q, want %q", loaded[0].ID, "case-1")
}
data, err := os.ReadFile(paths.TaskRecords)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "\"broken\"") {
t.Fatalf("expected test fixture to include broken trailing line")
}
}