1285 lines
40 KiB
Go
1285 lines
40 KiB
Go
package evolution_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/sipeed/picoclaw/pkg/config"
|
||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||
"github.com/sipeed/picoclaw/pkg/providers"
|
||
"github.com/sipeed/picoclaw/pkg/skills"
|
||
)
|
||
|
||
type stubDraftGenerator struct {
|
||
draft evolution.SkillDraft
|
||
err error
|
||
}
|
||
|
||
func (g stubDraftGenerator) GenerateDraft(
|
||
_ context.Context,
|
||
_ evolution.LearningRecord,
|
||
_ []skills.SkillInfo,
|
||
) (evolution.SkillDraft, error) {
|
||
return g.draft, g.err
|
||
}
|
||
|
||
type sequenceDraftGenerator struct {
|
||
results []draftGenerationResult
|
||
index int
|
||
}
|
||
|
||
type draftGenerationResult struct {
|
||
draft evolution.SkillDraft
|
||
err error
|
||
}
|
||
|
||
type evidenceCaptureDraftGenerator struct {
|
||
evidence evolution.DraftEvidence
|
||
}
|
||
|
||
func (g *evidenceCaptureDraftGenerator) GenerateDraft(
|
||
_ context.Context,
|
||
_ evolution.LearningRecord,
|
||
_ []skills.SkillInfo,
|
||
) (evolution.SkillDraft, error) {
|
||
return evolution.SkillDraft{}, nil
|
||
}
|
||
|
||
func (g *evidenceCaptureDraftGenerator) GenerateDraftWithEvidence(
|
||
_ context.Context,
|
||
_ evolution.LearningRecord,
|
||
_ []skills.SkillInfo,
|
||
evidence evolution.DraftEvidence,
|
||
) (evolution.SkillDraft, error) {
|
||
g.evidence = evidence
|
||
return evolution.SkillDraft{
|
||
ID: "draft-evidence",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindCreate,
|
||
HumanSummary: "weather helper",
|
||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nUse current workspace evidence.\n",
|
||
}, nil
|
||
}
|
||
|
||
type stubSuccessJudge struct {
|
||
decisions map[string]evolution.TaskSuccessDecision
|
||
calls []string
|
||
}
|
||
|
||
func (j *stubSuccessJudge) JudgeTaskRecord(
|
||
_ context.Context,
|
||
record evolution.LearningRecord,
|
||
) (evolution.TaskSuccessDecision, error) {
|
||
j.calls = append(j.calls, record.ID)
|
||
if decision, ok := j.decisions[record.ID]; ok {
|
||
return decision, nil
|
||
}
|
||
return evolution.TaskSuccessDecision{Success: true, Reason: "default success"}, nil
|
||
}
|
||
|
||
func (g *sequenceDraftGenerator) GenerateDraft(
|
||
_ context.Context,
|
||
_ evolution.LearningRecord,
|
||
_ []skills.SkillInfo,
|
||
) (evolution.SkillDraft, error) {
|
||
if g.index >= len(g.results) {
|
||
return evolution.SkillDraft{}, nil
|
||
}
|
||
result := g.results[g.index]
|
||
g.index++
|
||
return result.draft, result.err
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) {
|
||
root := t.TempDir()
|
||
paths := evolution.NewPaths(root, "")
|
||
store := evolution.NewStore(paths)
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-1",
|
||
WorkspaceID: root,
|
||
SourceRecordID: "rule-1",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer native-name path first",
|
||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||
},
|
||
},
|
||
Store: store,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_AdmitsOnlyRecordsApprovedBySuccessJudge(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
failed := false
|
||
|
||
records := []evolution.LearningRecord{
|
||
{
|
||
ID: "task-failed",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "failed weather attempt",
|
||
UserGoal: "check weather in shanghai",
|
||
FinalOutput: "tool failed",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &failed,
|
||
UsedSkillNames: []string{"weather", "native-name"},
|
||
ToolKinds: []string{"read_file"},
|
||
},
|
||
{
|
||
ID: "task-rejected",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
Summary: "partial weather answer",
|
||
UserGoal: "check weather in shanghai",
|
||
FinalOutput: "I will check it next",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather", "native-name"},
|
||
ToolKinds: []string{"read_file"},
|
||
ToolExecutions: []evolution.ToolExecutionRecord{
|
||
{Name: "read_file", Success: true},
|
||
{Name: "read_file", Success: true},
|
||
},
|
||
},
|
||
{
|
||
ID: "task-admitted",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||
Summary: "weather answer delivered",
|
||
UserGoal: "check weather in shanghai",
|
||
FinalOutput: "sunny, 26C",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather", "native-name"},
|
||
AddedSkillNames: []string{"native-name"},
|
||
ToolKinds: []string{"read_file"},
|
||
ToolExecutions: []evolution.ToolExecutionRecord{
|
||
{Name: "read_file", Success: true},
|
||
{Name: "read_file", Success: true},
|
||
},
|
||
AttemptTrail: &evolution.AttemptTrail{
|
||
AttemptedSkills: []string{"weather"},
|
||
FinalSuccessfulPath: []string{"weather"},
|
||
},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords(records); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
judge := &stubSuccessJudge{
|
||
decisions: map[string]evolution.TaskSuccessDecision{
|
||
"task-rejected": {Success: false, Reason: "only partial reasoning"},
|
||
"task-admitted": {Success: true, Reason: "goal achieved"},
|
||
},
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft", MinTaskCount: 1},
|
||
Store: store,
|
||
SuccessJudge: judge,
|
||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 1, MinSuccessRate: 1}),
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-weather",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer the proven weather path",
|
||
BodyOrPatch: "## Start Here\nUse the weather path directly.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
if len(judge.calls) != 2 || judge.calls[0] != "task-rejected" || judge.calls[1] != "task-admitted" {
|
||
t.Fatalf("judge calls = %v, want [task-rejected task-admitted]", judge.calls)
|
||
}
|
||
|
||
allRecords, err := store.LoadLearningRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadLearningRecords: %v", err)
|
||
}
|
||
|
||
var pattern evolution.LearningRecord
|
||
foundPattern := false
|
||
for _, record := range allRecords {
|
||
if record.Kind != evolution.RecordKindPattern {
|
||
continue
|
||
}
|
||
pattern = record
|
||
foundPattern = true
|
||
break
|
||
}
|
||
if !foundPattern {
|
||
t.Fatal("expected generated pattern record")
|
||
}
|
||
if len(pattern.TaskRecordIDs) != 1 || pattern.TaskRecordIDs[0] != "task-admitted" {
|
||
t.Fatalf("TaskRecordIDs = %v, want [task-admitted]", pattern.TaskRecordIDs)
|
||
}
|
||
if pattern.Label == "" {
|
||
t.Fatal("pattern Label should not be empty")
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].SourceRecordID != pattern.ID {
|
||
t.Fatalf("draft SourceRecordID = %q, want %q", drafts[0].SourceRecordID, pattern.ID)
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_RejectsClusterBelowMinSuccessRatio(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
failed := false
|
||
|
||
records := []evolution.LearningRecord{
|
||
{
|
||
ID: "task-success",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||
Summary: "weather lookup 100",
|
||
FinalOutput: "sunny",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
{
|
||
ID: "task-failed-1",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
Summary: "weather lookup 200",
|
||
FinalOutput: "failed",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &failed,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
{
|
||
ID: "task-failed-2",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather lookup 300",
|
||
FinalOutput: "failed",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &failed,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords(records); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft", MinTaskCount: 1, MinSuccessRatio: 0.8},
|
||
Store: store,
|
||
SuccessJudge: &stubSuccessJudge{},
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-weather",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer the proven weather path",
|
||
BodyOrPatch: "## Start Here\nUse the weather path directly.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
patterns, err := store.LoadPatternRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadPatternRecords: %v", err)
|
||
}
|
||
if len(patterns) != 0 {
|
||
t.Fatalf("len(patterns) = %d, want 0", len(patterns))
|
||
}
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 0 {
|
||
t.Fatalf("len(drafts) = %d, want 0", len(drafts))
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_FallbackUsesJudgeAdjustedSuccessRatio(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
|
||
records := []evolution.LearningRecord{
|
||
{
|
||
ID: "task-success",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||
Summary: "weather lookup 100",
|
||
FinalOutput: "sunny",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
{
|
||
ID: "task-judge-rejected",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
Summary: "weather lookup 200",
|
||
FinalOutput: "partial answer",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords(records); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
judge := &stubSuccessJudge{
|
||
decisions: map[string]evolution.TaskSuccessDecision{
|
||
"task-success": {Success: true, Reason: "goal achieved"},
|
||
"task-judge-rejected": {Success: false, Reason: "partial result"},
|
||
},
|
||
}
|
||
clusterer := evolution.NewLLMPatternClusterer(
|
||
&llmClusterTestProvider{content: `not-json`, defaultModel: "test-model"},
|
||
"test-model",
|
||
evolution.NewHeuristicPatternClusterer(1, nil),
|
||
1,
|
||
func() time.Time { return time.Unix(1700000000, 0).UTC() },
|
||
)
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft", MinTaskCount: 1, MinSuccessRatio: 0.8},
|
||
Store: store,
|
||
PatternClusterer: clusterer,
|
||
SuccessJudge: judge,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-weather",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer the proven weather path",
|
||
BodyOrPatch: "## Start Here\nUse the weather path directly.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
patterns, err := store.LoadPatternRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadPatternRecords: %v", err)
|
||
}
|
||
if len(patterns) != 0 {
|
||
t.Fatalf("len(patterns) = %d, want 0", len(patterns))
|
||
}
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 0 {
|
||
t.Fatalf("len(drafts) = %d, want 0", len(drafts))
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_FallbackMarksAcceptedFailureEvidenceClustered(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
|
||
records := []evolution.LearningRecord{
|
||
{
|
||
ID: "task-success",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||
Summary: "weather lookup 100",
|
||
FinalOutput: "sunny",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
{
|
||
ID: "task-judge-rejected",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
Summary: "weather lookup 200",
|
||
FinalOutput: "partial answer",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords(records); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
judge := &stubSuccessJudge{
|
||
decisions: map[string]evolution.TaskSuccessDecision{
|
||
"task-success": {Success: true, Reason: "goal achieved"},
|
||
"task-judge-rejected": {Success: false, Reason: "partial result"},
|
||
},
|
||
}
|
||
clusterer := evolution.NewLLMPatternClusterer(
|
||
&llmClusterTestProvider{content: `not-json`, defaultModel: "test-model"},
|
||
"test-model",
|
||
evolution.NewHeuristicPatternClusterer(1, nil),
|
||
1,
|
||
func() time.Time { return time.Unix(1700000000, 0).UTC() },
|
||
)
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft", MinTaskCount: 1, MinSuccessRatio: 0.5},
|
||
Store: store,
|
||
PatternClusterer: clusterer,
|
||
SuccessJudge: judge,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-weather",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer the proven weather path",
|
||
BodyOrPatch: "## Start Here\nUse the weather path directly.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
patterns, err := store.LoadPatternRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadPatternRecords: %v", err)
|
||
}
|
||
if len(patterns) != 1 {
|
||
t.Fatalf("len(patterns) = %d, want 1", len(patterns))
|
||
}
|
||
if got := strings.Join(patterns[0].TaskRecordIDs, ","); got != "task-success" {
|
||
t.Fatalf("pattern TaskRecordIDs = %v, want only successful task", patterns[0].TaskRecordIDs)
|
||
}
|
||
taskRecords, err := store.LoadTaskRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadTaskRecords: %v", err)
|
||
}
|
||
statusByID := make(map[string]evolution.RecordStatus)
|
||
for _, record := range taskRecords {
|
||
statusByID[record.ID] = record.Status
|
||
}
|
||
for _, id := range []string{"task-success", "task-judge-rejected"} {
|
||
if statusByID[id] != evolution.RecordStatus("clustered") {
|
||
t.Fatalf("statusByID[%s] = %q, want clustered", id, statusByID[id])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_DraftEvidenceDoesNotCrossWorkspaceWithDuplicateTaskID(t *testing.T) {
|
||
sharedState := t.TempDir()
|
||
workspaceA := t.TempDir()
|
||
workspaceB := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(workspaceA, sharedState))
|
||
ok := true
|
||
|
||
if err := store.AppendTaskRecords(context.Background(), []evolution.LearningRecord{
|
||
{
|
||
ID: "main-turn-1",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: workspaceB,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "other workspace weather",
|
||
FinalOutput: "foreign workspace output",
|
||
Status: evolution.RecordStatus("clustered"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"foreign-skill"},
|
||
},
|
||
{
|
||
ID: "main-turn-1",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: workspaceA,
|
||
CreatedAt: time.Unix(1700000001, 0).UTC(),
|
||
Summary: "current workspace weather",
|
||
FinalOutput: "current workspace output",
|
||
Status: evolution.RecordStatus("clustered"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"current-skill"},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("AppendTaskRecords: %v", err)
|
||
}
|
||
if err := store.AppendPatternRecords([]evolution.LearningRecord{{
|
||
ID: "pattern-workspace-a",
|
||
Kind: evolution.RecordKindPattern,
|
||
WorkspaceID: workspaceA,
|
||
CreatedAt: time.Unix(1700000002, 0).UTC(),
|
||
Summary: "current workspace weather",
|
||
Status: evolution.RecordStatus("ready"),
|
||
TaskRecordIDs: []string{"main-turn-1"},
|
||
}}); err != nil {
|
||
t.Fatalf("AppendPatternRecords: %v", err)
|
||
}
|
||
|
||
generator := &evidenceCaptureDraftGenerator{}
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft", StateDir: sharedState},
|
||
Store: store,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(workspaceA),
|
||
DraftGenerator: generator,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), workspaceA); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
if len(generator.evidence.TaskRecords) != 1 {
|
||
t.Fatalf(
|
||
"evidence task count = %d, want 1: %#v",
|
||
len(generator.evidence.TaskRecords),
|
||
generator.evidence.TaskRecords,
|
||
)
|
||
}
|
||
task := generator.evidence.TaskRecords[0]
|
||
if task.WorkspaceID != workspaceA {
|
||
t.Fatalf("evidence workspace = %q, want %q", task.WorkspaceID, workspaceA)
|
||
}
|
||
if task.FinalOutput != "current workspace output" {
|
||
t.Fatalf("evidence FinalOutput = %q, want current workspace output", task.FinalOutput)
|
||
}
|
||
if len(task.UsedSkillNames) != 1 || task.UsedSkillNames[0] != "current-skill" {
|
||
t.Fatalf("evidence UsedSkillNames = %v, want [current-skill]", task.UsedSkillNames)
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_AdmitsSingleSkillTaskButWaitsForMinTaskCount(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
|
||
record := evolution.LearningRecord{
|
||
ID: "task-simple",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000250, 0).UTC(),
|
||
Summary: "simple weather lookup",
|
||
UserGoal: "check weather",
|
||
FinalOutput: "sunny",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"weather"},
|
||
AddedSkillNames: []string{"weather"},
|
||
ToolKinds: []string{"read_file"},
|
||
ToolExecutions: []evolution.ToolExecutionRecord{
|
||
{Name: "read_file", Success: true, SkillNames: []string{"weather"}},
|
||
},
|
||
AttemptTrail: &evolution.AttemptTrail{
|
||
AttemptedSkills: []string{"weather"},
|
||
FinalSuccessfulPath: []string{"weather"},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{record}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
judge := &stubSuccessJudge{}
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
SuccessJudge: judge,
|
||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 1, MinSuccessRate: 1}),
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-simple",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "simple draft",
|
||
BodyOrPatch: "## Start Here\nUse weather.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
if len(judge.calls) != 1 || judge.calls[0] != "task-simple" {
|
||
t.Fatalf("judge calls = %v, want [task-simple]", judge.calls)
|
||
}
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 0 {
|
||
t.Fatalf("len(drafts) = %d, want 0", len(drafts))
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_RejectsTaskWhenSuccessJudgeRejects(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
ok := true
|
||
|
||
record := evolution.LearningRecord{
|
||
ID: "task-detailed-path",
|
||
Kind: evolution.RecordKindTask,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000300, 0).UTC(),
|
||
Summary: "computed theorem chain",
|
||
UserGoal: "调用三一定理计算100",
|
||
FinalOutput: "最终结果:100 通过三一定理计算得到 120",
|
||
Status: evolution.RecordStatus("new"),
|
||
Success: &ok,
|
||
UsedSkillNames: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
|
||
AddedSkillNames: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
|
||
ToolKinds: []string{"read_file"},
|
||
ToolExecutions: []evolution.ToolExecutionRecord{
|
||
{Name: "read_file", Success: true, SkillNames: []string{"three-one-theorem"}},
|
||
{Name: "read_file", Success: true, SkillNames: []string{"four-two-theorem"}},
|
||
{Name: "read_file", Success: true, SkillNames: []string{"five-three-theorem"}},
|
||
},
|
||
AttemptTrail: &evolution.AttemptTrail{
|
||
AttemptedSkills: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
|
||
FinalSuccessfulPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{record}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
judge := &stubSuccessJudge{
|
||
decisions: map[string]evolution.TaskSuccessDecision{
|
||
"task-detailed-path": {Success: false, Reason: "llm false negative"},
|
||
},
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
SuccessJudge: judge,
|
||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 1, MinSuccessRate: 1}),
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-detailed-path",
|
||
TargetSkillName: "three-one-theorem",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer the full theorem chain",
|
||
BodyOrPatch: "## Start Here\nUse the full three-one, four-two, five-three theorem chain.",
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
allRecords, err := store.LoadLearningRecords()
|
||
if err != nil {
|
||
t.Fatalf("LoadLearningRecords: %v", err)
|
||
}
|
||
|
||
foundPattern := false
|
||
for _, record := range allRecords {
|
||
if record.Kind != evolution.RecordKindPattern {
|
||
continue
|
||
}
|
||
foundPattern = true
|
||
break
|
||
}
|
||
if foundPattern {
|
||
t.Fatal("unexpected pattern record for rejected task")
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "release path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-1",
|
||
WorkspaceID: root,
|
||
SourceRecordID: "rule-1",
|
||
TargetSkillName: "",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "broken",
|
||
BodyOrPatch: "",
|
||
},
|
||
},
|
||
Store: store,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].Status != evolution.DraftStatusQuarantined {
|
||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined)
|
||
}
|
||
if len(drafts[0].ScanFindings) == 0 {
|
||
t.Fatal("expected scan findings for invalid draft")
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_DoesNotWriteSkillFile(t *testing.T) {
|
||
root := t.TempDir()
|
||
skillPath := filepath.Join(root, "skills", "weather", "SKILL.md")
|
||
if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil {
|
||
t.Fatalf("MkdirAll: %v", err)
|
||
}
|
||
if err := os.WriteFile(
|
||
skillPath,
|
||
[]byte("---\nname: weather\ndescription: test\n---\n# Weather"),
|
||
0o644,
|
||
); err != nil {
|
||
t.Fatalf("WriteFile: %v", err)
|
||
}
|
||
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
original, err := os.ReadFile(skillPath)
|
||
if err != nil {
|
||
t.Fatalf("ReadFile(original): %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-1",
|
||
WorkspaceID: root,
|
||
SourceRecordID: "rule-1",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer native-name path first",
|
||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||
},
|
||
},
|
||
Store: store,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
got, err := os.ReadFile(skillPath)
|
||
if err != nil {
|
||
t.Fatalf("ReadFile(after): %v", err)
|
||
}
|
||
if string(got) != string(original) {
|
||
t.Fatalf("skill file changed unexpectedly:\n%s", string(got))
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
SuccessRate: 1,
|
||
WinningPath: []string{"weather"},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].TargetSkillName != "weather" {
|
||
t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName)
|
||
}
|
||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||
}
|
||
if drafts[0].BodyOrPatch == "" {
|
||
t.Fatal("expected generated draft body")
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
SuccessRate: 1,
|
||
WinningPath: []string{"weather"},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
provider := &llmDraftRuntimeProvider{
|
||
response: &providers.LLMResponse{
|
||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`,
|
||
},
|
||
}
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, provider, "runtime-explicit-model"),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if provider.calls != 1 {
|
||
t.Fatalf("provider.calls = %d, want 1", provider.calls)
|
||
}
|
||
if drafts[0].HumanSummary != "Prefer native-name path first" {
|
||
t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first")
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvider(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
SuccessRate: 1,
|
||
WinningPath: []string{"weather"},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, nil, ""),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].TargetSkillName != "weather" {
|
||
t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName)
|
||
}
|
||
if drafts[0].BodyOrPatch == "" {
|
||
t.Fatal("expected generated draft body")
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
if err := os.MkdirAll(filepath.Join(root, "skills", "weather"), 0o755); err != nil {
|
||
t.Fatalf("MkdirAll: %v", err)
|
||
}
|
||
skillBody := "---\nname: weather\ndescription: workspace weather helper\n---\n# Weather\n## Start Here\nUse the workspace-specific path.\n"
|
||
if err := os.WriteFile(filepath.Join(root, "skills", "weather", "SKILL.md"), []byte(skillBody), 0o644); err != nil {
|
||
t.Fatalf("WriteFile: %v", err)
|
||
}
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
SuccessRate: 1,
|
||
WinningPath: []string{"weather"},
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
provider := &llmDraftRuntimeProvider{
|
||
response: &providers.LLMResponse{Content: `not-json`},
|
||
defaultModel: "runtime-test-model",
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
|
||
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, "runtime-explicit-model")
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].ChangeKind != evolution.ChangeKindAppend {
|
||
t.Fatalf("ChangeKind = %q, want %q", drafts[0].ChangeKind, evolution.ChangeKindAppend)
|
||
}
|
||
if !strings.Contains(drafts[0].BodyOrPatch, "## Learned Evolution") {
|
||
t.Fatalf("BodyOrPatch = %q, want appended learned evolution section", drafts[0].BodyOrPatch)
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_PersistsEarlierDraftWhenLaterRuleFails(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rules := []evolution.LearningRecord{
|
||
{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
},
|
||
{
|
||
ID: "rule-2",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
Summary: "release path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
},
|
||
}
|
||
if err := store.AppendLearningRecords(rules); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
|
||
generator := &sequenceDraftGenerator{
|
||
results: []draftGenerationResult{
|
||
{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-1",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "prefer native-name path first",
|
||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||
},
|
||
},
|
||
{
|
||
err: context.DeadlineExceeded,
|
||
},
|
||
},
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
DraftGenerator: generator,
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
err = rt.RunColdPathOnce(context.Background(), root)
|
||
if !errors.Is(err, context.DeadlineExceeded) {
|
||
t.Fatalf("RunColdPathOnce error = %v, want %v", err, context.DeadlineExceeded)
|
||
}
|
||
|
||
drafts, loadErr := store.LoadDrafts()
|
||
if loadErr != nil {
|
||
t.Fatalf("LoadDrafts: %v", loadErr)
|
||
}
|
||
if len(drafts) != 1 {
|
||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||
}
|
||
if drafts[0].SourceRecordID != "rule-1" {
|
||
t.Fatalf("SourceRecordID = %q, want rule-1", drafts[0].SourceRecordID)
|
||
}
|
||
}
|
||
|
||
func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(t *testing.T) {
|
||
root := t.TempDir()
|
||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||
|
||
rule := evolution.LearningRecord{
|
||
ID: "rule-1",
|
||
Kind: evolution.RecordKindRule,
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||
Summary: "weather native-name path",
|
||
Status: evolution.RecordStatus("ready"),
|
||
EventCount: 4,
|
||
}
|
||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||
t.Fatalf("AppendLearningRecords: %v", err)
|
||
}
|
||
if err := store.SaveDrafts([]evolution.SkillDraft{{
|
||
ID: "draft-old",
|
||
WorkspaceID: root,
|
||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||
SourceRecordID: "rule-1",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "broken attempt",
|
||
BodyOrPatch: "## Start Here\nBroken content.",
|
||
Status: evolution.DraftStatusQuarantined,
|
||
ScanFindings: []string{"apply failed"},
|
||
}}); err != nil {
|
||
t.Fatalf("SaveDrafts: %v", err)
|
||
}
|
||
|
||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
|
||
Store: store,
|
||
DraftGenerator: stubDraftGenerator{
|
||
draft: evolution.SkillDraft{
|
||
ID: "draft-new",
|
||
TargetSkillName: "weather",
|
||
DraftType: evolution.DraftTypeShortcut,
|
||
ChangeKind: evolution.ChangeKindAppend,
|
||
HumanSummary: "fixed attempt",
|
||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||
},
|
||
},
|
||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewRuntime: %v", err)
|
||
}
|
||
|
||
if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
|
||
t.Fatalf("RunColdPathOnce: %v", runErr)
|
||
}
|
||
|
||
drafts, err := store.LoadDrafts()
|
||
if err != nil {
|
||
t.Fatalf("LoadDrafts: %v", err)
|
||
}
|
||
if len(drafts) != 2 {
|
||
t.Fatalf("len(drafts) = %d, want 2", len(drafts))
|
||
}
|
||
if drafts[1].ID != "draft-new" {
|
||
t.Fatalf("drafts[1].ID = %q, want draft-new", drafts[1].ID)
|
||
}
|
||
}
|
||
|
||
type llmDraftRuntimeProvider struct {
|
||
response *providers.LLMResponse
|
||
err error
|
||
calls int
|
||
defaultModel string
|
||
}
|
||
|
||
func (p *llmDraftRuntimeProvider) Chat(
|
||
_ context.Context,
|
||
_ []providers.Message,
|
||
_ []providers.ToolDefinition,
|
||
_ string,
|
||
_ map[string]any,
|
||
) (*providers.LLMResponse, error) {
|
||
p.calls++
|
||
return p.response, p.err
|
||
}
|
||
|
||
func (p *llmDraftRuntimeProvider) GetDefaultModel() string {
|
||
if p.defaultModel != "" {
|
||
return p.defaultModel
|
||
}
|
||
return "runtime-test-model"
|
||
}
|