mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b3a7b7ad64
* feat: add agent self-evolution * fix ci * delete unused doc * fix lint * fix evolution review issues
439 lines
13 KiB
Go
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")
|
|
}
|
|
}
|