picoclaw/pkg/agent/evolution_bridge_test.go

810 lines
26 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestEvolutionBridge_DisabledWritesNothing(t *testing.T) {
tmpDir := t.TempDir()
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: false,
Mode: "observe",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-disabled", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "ok" {
t.Fatalf("response = %q, want %q", resp, "ok")
}
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_ObserveWritesCaseRecord(t *testing.T) {
tmpDir := t.TempDir()
provider := &toolCallRespProvider{
toolName: "echo_text",
toolArgs: map[string]any{"text": "bridge"},
response: "done",
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "observe",
}, provider)
defer al.Close()
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
defaultAgent.SkillsFilter = []string{"observe-skill"}
al.RegisterTool(&echoTextTool{})
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "done" {
t.Fatalf("response = %q, want %q", resp, "done")
}
record := waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
if got := record["kind"]; got != string(evolution.RecordKindCase) {
t.Fatalf("kind = %v, want %q", got, evolution.RecordKindCase)
}
if got := record["workspace_id"]; got != tmpDir {
t.Fatalf("workspace_id = %v, want %q", got, tmpDir)
}
if got := record["status"]; got != "new" {
t.Fatalf("status = %v, want %q", got, "new")
}
toolKinds, ok := record["tool_kinds"].([]any)
if !ok {
t.Fatalf("tool_kinds missing or wrong type: %#v", record["tool_kinds"])
}
if len(toolKinds) != 1 || toolKinds[0] != "echo_text" {
t.Fatalf("tool_kinds = %#v, want [echo_text]", toolKinds)
}
activeSkillsRaw, exists := record["initial_skill_names"]
if !exists {
t.Fatal("initial_skill_names field missing")
}
activeSkills, ok := activeSkillsRaw.([]any)
if !ok {
t.Fatalf("initial_skill_names wrong type: %#v", activeSkillsRaw)
}
if len(activeSkills) != 1 || activeSkills[0] != "observe-skill" {
t.Fatalf("initial_skill_names = %#v, want [observe-skill]", activeSkills)
}
toolExecsRaw, exists := record["tool_executions"]
if !exists {
t.Fatal("tool_executions field missing")
}
toolExecs, ok := toolExecsRaw.([]any)
if !ok || len(toolExecs) != 1 {
t.Fatalf("tool_executions wrong type: %#v", toolExecsRaw)
}
}
func TestEvolutionBridge_ObserveTurnEndPayloadIncludesResolvedAttemptTrail(t *testing.T) {
tmpDir := t.TempDir()
skillDir := filepath.Join(tmpDir, "skills", "observe-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: observe-skill\ndescription: observe test skill\n---\n# Observe Skill\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err)
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "observe",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
defaultAgent.SkillsFilter = []string{"missing-skill", "observe-skill", "observe-skill"}
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-attempt-trail", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "ok" {
t.Fatalf("response = %q, want %q", resp, "ok")
}
turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool {
return evt.Kind == EventKindTurnEnd
})
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
if !ok {
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
}
if got := turnEndPayload.AttemptedSkills; len(got) != 1 || got[0] != "observe-skill" {
t.Fatalf("AttemptedSkills = %v, want [observe-skill]", got)
}
if got := turnEndPayload.FinalSuccessfulPath; len(got) != 1 || got[0] != "observe-skill" {
t.Fatalf("FinalSuccessfulPath = %v, want [observe-skill]", got)
}
if got := turnEndPayload.SkillContextSnapshots; len(got) != 1 || got[0].Trigger != skillContextTriggerInitialBuild {
t.Fatalf("SkillContextSnapshots = %+v, want single initial_build snapshot", got)
}
}
func TestEvolutionBridge_ObserveTurnEndUsesLatestSkillSnapshotAfterRetry(t *testing.T) {
tmpDir := t.TempDir()
baseSkillDir := filepath.Join(tmpDir, "skills", "base-skill")
if err := os.MkdirAll(baseSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(baseSkillDir): %v", err)
}
if err := os.WriteFile(
filepath.Join(baseSkillDir, "SKILL.md"),
[]byte("---\nname: base-skill\ndescription: base test skill\n---\n# Base Skill\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(base-skill): %v", err)
}
lateSkillPath := filepath.Join(tmpDir, "skills", "late-skill", "SKILL.md")
provider := &lateSkillOnRetryProvider{lateSkillPath: lateSkillPath}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "observe",
}, provider)
defer al.Close()
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
defaultAgent.SkillsFilter = []string{"base-skill", "late-skill"}
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-retry-snapshot", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "Recovered after retry" {
t.Fatalf("response = %q, want %q", resp, "Recovered after retry")
}
turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool {
return evt.Kind == EventKindTurnEnd
})
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
if !ok {
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
}
if got := turnEndPayload.AttemptedSkills; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
t.Fatalf("AttemptedSkills = %v, want [base-skill late-skill]", got)
}
if got := turnEndPayload.FinalSuccessfulPath; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
t.Fatalf("FinalSuccessfulPath = %v, want [base-skill late-skill]", got)
}
if got := turnEndPayload.SkillContextSnapshots; len(got) != 2 {
t.Fatalf("len(SkillContextSnapshots) = %d, want 2", len(got))
}
if turnEndPayload.SkillContextSnapshots[0].Trigger != skillContextTriggerInitialBuild {
t.Fatalf("SkillContextSnapshots[0].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[0].Trigger, skillContextTriggerInitialBuild)
}
if turnEndPayload.SkillContextSnapshots[1].Trigger != skillContextTriggerContextRetryRebuild {
t.Fatalf("SkillContextSnapshots[1].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[1].Trigger, skillContextTriggerContextRetryRebuild)
}
if got := turnEndPayload.SkillContextSnapshots[1].SkillNames; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
t.Fatalf("SkillContextSnapshots[1].SkillNames = %v, want [base-skill late-skill]", got)
}
}
func TestEvolutionBridge_ObserveDoesNotCreateDraftFile(t *testing.T) {
tmpDir := t.TempDir()
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "observe",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-no-draft", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "ok" {
t.Fatalf("response = %q, want %q", resp, "ok")
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "ok" {
t.Fatalf("response = %q, want %q", resp, "ok")
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
}
func TestEvolutionBridge_DraftModeUsesProviderBackedDraftGenerator(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"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."}`,
})
defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-llm", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp == "" {
t.Fatal("expected non-empty response")
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
if drafts[0].HumanSummary != "Prefer native-name path first" {
t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first")
}
}
func TestEvolutionBridge_DraftModeUsesProviderDefaultModel(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
provider := &capturingEvolutionDraftProvider{
defaultModel: "provider-explicit-model",
response: `{"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."}`,
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, provider)
defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-model", "cli", "direct"); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
if provider.lastModel != "provider-explicit-model" {
t.Fatalf("lastModel = %q, want provider-explicit-model", provider.lastModel)
}
}
func TestEvolutionBridge_DraftModeKeepsCandidateDraft(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"create","human_summary":"Create weather helper","body_or_patch":"---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n"}`,
})
defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-no-auto-apply", "cli", "direct"); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
if drafts[0].Status != evolution.DraftStatusCandidate {
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
}
assertNotExists(t, filepath.Join(tmpDir, "skills", "weather", "SKILL.md"))
assertProfileNotExists(t, tmpDir, "weather")
}
func TestEvolutionBridge_ApplyModeAutomaticallyRunsColdPathAndAppliesMergeDraft(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
skillDir := filepath.Join(tmpDir, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names.\n"
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "apply",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"merge","human_summary":"Merge native-name path","body_or_patch":"Prefer native-name query first."}`,
})
defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-merge", "cli", "direct"); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
if drafts[0].Status != evolution.DraftStatusAccepted {
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted)
}
merged := waitForSkillBody(t, skillPath)
if !strings.Contains(merged, "Use city names.") {
t.Fatalf("merged skill lost original content:\n%s", merged)
}
if !strings.Contains(merged, "## Merged Knowledge") {
t.Fatalf("merged skill missing merged section:\n%s", merged)
}
if !strings.Contains(merged, "Prefer native-name query first.") {
t.Fatalf("merged skill missing learned knowledge:\n%s", merged)
}
profile := waitForProfile(t, tmpDir, "weather")
if profile.Status != evolution.SkillStatusActive {
t.Fatalf("profile status = %q, want %q", profile.Status, evolution.SkillStatusActive)
}
if profile.CurrentVersion == "" {
t.Fatal("expected applied profile current version")
}
}
func TestEvolutionBridge_ObserveModeDoesNotRunColdPathOrCreateDraftFile(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "observe",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-no-auto-cold-path", "cli", "direct")
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
if resp != "ok" {
t.Fatalf("response = %q, want %q", resp, "ok")
}
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_TurnEndUsesPayloadWorkspace(t *testing.T) {
workspace := t.TempDir()
cfg := &config.Config{
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "observe",
},
}
bridge, err := newEvolutionBridge(nil, cfg, nil)
if err != nil {
t.Fatalf("newEvolutionBridge: %v", err)
}
err = bridge.OnEvent(context.Background(), Event{
Kind: EventKindTurnEnd,
Meta: EventMeta{
AgentID: "main",
TurnID: "turn-1",
SessionKey: "session-1",
},
Payload: TurnEndPayload{
Status: TurnEndStatusCompleted,
Workspace: workspace,
ActiveSkills: []string{"observe-skill"},
ToolKinds: []string{"echo_text"},
},
})
if err != nil {
t.Fatalf("OnEvent: %v", err)
}
record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl"))
if got := record["workspace_id"]; got != workspace {
t.Fatalf("workspace_id = %v, want %q", got, workspace)
}
}
func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) {
workspace := t.TempDir()
cfg := &config.Config{
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "observe",
},
}
bridge, err := newEvolutionBridge(nil, cfg, nil)
if err != nil {
t.Fatalf("newEvolutionBridge: %v", err)
}
err = bridge.OnEvent(context.Background(), Event{
Kind: EventKindTurnEnd,
Meta: EventMeta{
AgentID: "main",
TurnID: "turn-1",
SessionKey: "session-1",
},
Payload: TurnEndPayload{
Status: TurnEndStatusCompleted,
Workspace: workspace,
ActiveSkills: []string{"weather"},
AttemptedSkills: []string{"geocode", "weather"},
FinalSuccessfulPath: []string{"geocode", "weather"},
SkillContextSnapshots: []SkillContextSnapshot{
{Sequence: 1, Trigger: skillContextTriggerInitialBuild, SkillNames: []string{"weather"}},
{Sequence: 2, Trigger: skillContextTriggerContextRetryRebuild, SkillNames: []string{"geocode", "weather"}},
},
ToolKinds: []string{"echo_text"},
},
})
if err != nil {
t.Fatalf("OnEvent: %v", err)
}
record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl"))
attemptTrailRaw, ok := record["attempt_trail"].(map[string]any)
if !ok {
t.Fatalf("attempt_trail missing or wrong type: %#v", record["attempt_trail"])
}
attemptedSkills, ok := attemptTrailRaw["attempted_skills"].([]any)
if !ok {
t.Fatalf("attempted_skills wrong type: %#v", attemptTrailRaw["attempted_skills"])
}
if len(attemptedSkills) != 2 || attemptedSkills[0] != "geocode" || attemptedSkills[1] != "weather" {
t.Fatalf("attempted_skills = %#v, want [geocode weather]", attemptedSkills)
}
finalPath, ok := attemptTrailRaw["final_successful_path"].([]any)
if !ok {
t.Fatalf("final_successful_path wrong type: %#v", attemptTrailRaw["final_successful_path"])
}
if len(finalPath) != 2 || finalPath[0] != "geocode" || finalPath[1] != "weather" {
t.Fatalf("final_successful_path = %#v, want [geocode weather]", finalPath)
}
if _, exists := attemptTrailRaw["skill_context_snapshots"]; exists {
t.Fatalf("skill_context_snapshots should not be persisted: %#v", attemptTrailRaw["skill_context_snapshots"])
}
initialSkills, ok := record["initial_skill_names"].([]any)
if !ok || len(initialSkills) != 1 || initialSkills[0] != "weather" {
t.Fatalf("initial_skill_names = %#v, want [weather]", record["initial_skill_names"])
}
addedSkills, ok := record["added_skill_names"].([]any)
if !ok || len(addedSkills) != 1 || addedSkills[0] != "geocode" {
t.Fatalf("added_skill_names = %#v, want [geocode]", record["added_skill_names"])
}
}
func TestEvolutionBridge_CloseStopsColdPathRunnerIdempotently(t *testing.T) {
cfg := &config.Config{
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "draft",
},
}
bridge, err := newEvolutionBridge(nil, cfg, nil)
if err != nil {
t.Fatalf("newEvolutionBridge: %v", err)
}
if bridge.coldPathRunner == nil {
t.Fatal("expected cold path runner")
}
if err := bridge.Close(); err != nil {
t.Fatalf("first Close() error = %v", err)
}
if err := bridge.Close(); err != nil {
t.Fatalf("second Close() error = %v", err)
}
if bridge.coldPathRunner.Trigger(t.TempDir()) {
t.Fatal("expected closed bridge runner to reject new work")
}
}
func TestAgentLoop_ReloadProviderAndConfig_RebuildsEvolutionBridge(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 3,
},
},
Evolution: config.EvolutionConfig{
Enabled: false,
Mode: "observe",
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
defer al.Close()
oldBridge := al.evolution
if oldBridge == nil {
t.Fatal("expected initial evolution bridge")
}
reloadCfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 3,
},
},
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "apply",
StateDir: filepath.Join(t.TempDir(), "evolution-state"),
},
}
if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloadCfg); err != nil {
t.Fatalf("ReloadProviderAndConfig failed: %v", err)
}
if al.evolution == nil {
t.Fatal("expected evolution bridge after reload")
}
if al.evolution == oldBridge {
t.Fatal("expected evolution bridge to be rebuilt on reload")
}
if al.evolution.cfg.Enabled != reloadCfg.Evolution.Enabled {
t.Fatalf("reloaded evolution enabled = %v, want %v", al.evolution.cfg.Enabled, reloadCfg.Evolution.Enabled)
}
if al.evolution.cfg.Mode != reloadCfg.Evolution.Mode {
t.Fatalf("reloaded evolution mode = %q, want %q", al.evolution.cfg.Mode, reloadCfg.Evolution.Mode)
}
if al.evolution.cfg.StateDir != reloadCfg.Evolution.StateDir {
t.Fatalf("reloaded evolution state_dir = %q, want %q", al.evolution.cfg.StateDir, reloadCfg.Evolution.StateDir)
}
}
func seedReadyRule(t *testing.T, workspace string) {
t.Helper()
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
rule := evolution.LearningRecord{
ID: "rule-1",
Kind: evolution.RecordKindRule,
WorkspaceID: workspace,
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)
}
}
func newEvolutionTestLoop(t *testing.T, workspace string, evo config.EvolutionConfig, provider providers.LLMProvider) *AgentLoop {
t.Helper()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 3,
},
},
Evolution: evo,
}
return NewAgentLoop(cfg, bus.NewMessageBus(), provider)
}
func waitForEvolutionRecord(t *testing.T, path string) map[string]any {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) == "" {
continue
}
var record map[string]any
if err := json.Unmarshal([]byte(lines[i]), &record); err != nil {
t.Fatalf("json.Unmarshal(%s): %v", path, err)
}
if kind, _ := record["kind"].(string); kind == string(evolution.RecordKindTask) {
return record
}
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for evolution record at %s", path)
return nil
}
func waitForDrafts(t *testing.T, path string, want int) []evolution.SkillDraft {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
var drafts []evolution.SkillDraft
if err := json.Unmarshal(data, &drafts); err != nil {
t.Fatalf("json.Unmarshal(%s): %v", path, err)
}
if len(drafts) == want {
return drafts
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d drafts at %s", want, path)
return nil
}
func waitForSkillBody(t *testing.T, path string) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
return string(data)
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for skill file at %s", path)
return ""
}
func waitForProfile(t *testing.T, workspace, skillName string) evolution.SkillProfile {
t.Helper()
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
profile, err := store.LoadProfile(skillName)
if err == nil {
return profile
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for profile %q in %s", skillName, workspace)
return evolution.SkillProfile{}
}
func assertProfileNotExists(t *testing.T, workspace, skillName string) {
t.Helper()
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if _, err := store.LoadProfile(skillName); !os.IsNotExist(err) {
t.Fatalf("profile %q should not exist, got err = %v", skillName, err)
}
}
func assertNotExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%s should not exist, stat err = %v", path, err)
}
}
type capturingEvolutionDraftProvider struct {
response string
defaultModel string
lastModel string
}
type lateSkillOnRetryProvider struct {
calls int
lateSkillPath string
}
func (p *lateSkillOnRetryProvider) Chat(
_ context.Context,
_ []providers.Message,
_ []providers.ToolDefinition,
_ string,
_ map[string]any,
) (*providers.LLMResponse, error) {
p.calls++
if p.calls == 1 {
if err := os.MkdirAll(filepath.Dir(p.lateSkillPath), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(
p.lateSkillPath,
[]byte("---\nname: late-skill\ndescription: late test skill\n---\n# Late Skill\n"),
0o644,
); err != nil {
return nil, err
}
return nil, errors.New("context_window_exceeded")
}
return &providers.LLMResponse{Content: "Recovered after retry"}, nil
}
func (p *lateSkillOnRetryProvider) GetDefaultModel() string {
return "mock-model"
}
func (p *capturingEvolutionDraftProvider) Chat(
_ context.Context,
_ []providers.Message,
_ []providers.ToolDefinition,
model string,
_ map[string]any,
) (*providers.LLMResponse, error) {
p.lastModel = model
return &providers.LLMResponse{Content: p.response}, nil
}
func (p *capturingEvolutionDraftProvider) GetDefaultModel() string {
return p.defaultModel
}