diff --git a/pkg/agentic/plan_test.go b/pkg/agentic/plan_test.go new file mode 100644 index 0000000..c3e2603 --- /dev/null +++ b/pkg/agentic/plan_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "path/filepath" + "strings" + "testing" + + coreio "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlanPath_Good(t *testing.T) { + assert.Equal(t, "/tmp/plans/my-plan-abc123.json", planPath("/tmp/plans", "my-plan-abc123")) + assert.Equal(t, "/data/test.json", planPath("/data", "test")) +} + +func TestWritePlan_Good(t *testing.T) { + dir := t.TempDir() + plan := &Plan{ + ID: "test-plan-abc123", + Title: "Test Plan", + Status: "draft", + Objective: "Test the plan system", + } + + path, err := writePlan(dir, plan) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "test-plan-abc123.json"), path) + + // Verify file exists + assert.True(t, coreio.Local.IsFile(path)) +} + +func TestWritePlan_Good_CreatesDirectory(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "nested", "plans") + + plan := &Plan{ + ID: "nested-plan-abc123", + Title: "Nested", + Status: "draft", + Objective: "Test nested directory creation", + } + + path, err := writePlan(dir, plan) + require.NoError(t, err) + assert.Contains(t, path, "nested-plan-abc123.json") +} + +func TestReadPlan_Good(t *testing.T) { + dir := t.TempDir() + original := &Plan{ + ID: "read-test-abc123", + Title: "Read Test", + Status: "ready", + Repo: "go-io", + Org: "core", + Objective: "Verify plan reading works", + Phases: []Phase{ + {Number: 1, Name: "Setup", Status: "done"}, + {Number: 2, Name: "Implement", Status: "pending"}, + }, + Notes: "Some notes", + Agent: "claude:opus", + } + + _, err := writePlan(dir, original) + require.NoError(t, err) + + read, err := readPlan(dir, "read-test-abc123") + require.NoError(t, err) + + assert.Equal(t, original.ID, read.ID) + assert.Equal(t, original.Title, read.Title) + assert.Equal(t, original.Status, read.Status) + assert.Equal(t, original.Repo, read.Repo) + assert.Equal(t, original.Org, read.Org) + assert.Equal(t, original.Objective, read.Objective) + assert.Len(t, read.Phases, 2) + assert.Equal(t, "Setup", read.Phases[0].Name) + assert.Equal(t, "done", read.Phases[0].Status) + assert.Equal(t, "Implement", read.Phases[1].Name) + assert.Equal(t, "pending", read.Phases[1].Status) + assert.Equal(t, "Some notes", read.Notes) + assert.Equal(t, "claude:opus", read.Agent) +} + +func TestReadPlan_Bad_NotFound(t *testing.T) { + dir := t.TempDir() + _, err := readPlan(dir, "nonexistent-plan") + assert.Error(t, err) +} + +func TestReadPlan_Bad_InvalidJSON(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "bad-json.json"), "{broken")) + + _, err := readPlan(dir, "bad-json") + assert.Error(t, err) +} + +func TestWriteReadPlan_Good_Roundtrip(t *testing.T) { + dir := t.TempDir() + + plan := &Plan{ + ID: "roundtrip-abc123", + Title: "Roundtrip Test", + Status: "in_progress", + Repo: "agent", + Org: "core", + Objective: "Ensure write-read roundtrip works", + Phases: []Phase{ + {Number: 1, Name: "Phase One", Status: "done", Criteria: []string{"tests pass", "coverage > 80%"}, Tests: 5}, + {Number: 2, Name: "Phase Two", Status: "in_progress", Notes: "Working on it"}, + {Number: 3, Name: "Phase Three", Status: "pending"}, + }, + Notes: "Important plan", + Agent: "gemini", + } + + _, err := writePlan(dir, plan) + require.NoError(t, err) + + read, err := readPlan(dir, "roundtrip-abc123") + require.NoError(t, err) + + assert.Equal(t, plan.Title, read.Title) + assert.Equal(t, plan.Status, read.Status) + assert.Len(t, read.Phases, 3) + assert.Equal(t, []string{"tests pass", "coverage > 80%"}, read.Phases[0].Criteria) + assert.Equal(t, 5, read.Phases[0].Tests) + assert.Equal(t, "Working on it", read.Phases[1].Notes) +} + +func TestGeneratePlanID_Good_Slugifies(t *testing.T) { + id := generatePlanID("Add Unit Tests for Agentic") + assert.True(t, strings.HasPrefix(id, "add-unit-tests-for-agentic"), "got: %s", id) + // Should have random suffix + parts := strings.Split(id, "-") + assert.True(t, len(parts) >= 5, "expected slug with random suffix, got: %s", id) +} + +func TestGeneratePlanID_Good_TruncatesLong(t *testing.T) { + id := generatePlanID("This is a very long title that should be truncated to a reasonable length for file naming purposes") + // Slug part (before random suffix) should be <= 30 chars + lastDash := strings.LastIndex(id, "-") + slug := id[:lastDash] + assert.True(t, len(slug) <= 36, "slug too long: %s (%d chars)", slug, len(slug)) +} + +func TestGeneratePlanID_Good_HandlesSpecialChars(t *testing.T) { + id := generatePlanID("Fix bug #123: auth & session!") + assert.True(t, strings.Contains(id, "fix-bug"), "got: %s", id) + assert.NotContains(t, id, "#") + assert.NotContains(t, id, "!") + assert.NotContains(t, id, "&") +} + +func TestGeneratePlanID_Good_Unique(t *testing.T) { + id1 := generatePlanID("Same Title") + id2 := generatePlanID("Same Title") + assert.NotEqual(t, id1, id2, "IDs should differ due to random suffix") +} + +func TestValidPlanStatus_Good_AllValid(t *testing.T) { + validStatuses := []string{"draft", "ready", "in_progress", "needs_verification", "verified", "approved"} + for _, s := range validStatuses { + assert.True(t, validPlanStatus(s), "expected %q to be valid", s) + } +} + +func TestValidPlanStatus_Bad_Invalid(t *testing.T) { + invalidStatuses := []string{"", "running", "completed", "cancelled", "archived", "DRAFT", "Draft"} + for _, s := range invalidStatuses { + assert.False(t, validPlanStatus(s), "expected %q to be invalid", s) + } +} + +func TestWritePlan_Good_OverwriteExisting(t *testing.T) { + dir := t.TempDir() + + plan := &Plan{ + ID: "overwrite-abc123", + Title: "Original", + Status: "draft", + Objective: "Original objective", + } + + _, err := writePlan(dir, plan) + require.NoError(t, err) + + plan.Title = "Updated" + plan.Status = "ready" + _, err = writePlan(dir, plan) + require.NoError(t, err) + + read, err := readPlan(dir, "overwrite-abc123") + require.NoError(t, err) + assert.Equal(t, "Updated", read.Title) + assert.Equal(t, "ready", read.Status) +} + +func TestReadPlan_Ugly_EmptyFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "empty.json"), "")) + + _, err := readPlan(dir, "empty") + assert.Error(t, err) +} diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go new file mode 100644 index 0000000..658b608 --- /dev/null +++ b/pkg/agentic/prep_test.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "path/filepath" + "testing" + + coreio "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnvOr_Good_EnvSet(t *testing.T) { + t.Setenv("TEST_ENVVAR_CUSTOM", "custom-value") + assert.Equal(t, "custom-value", envOr("TEST_ENVVAR_CUSTOM", "default")) +} + +func TestEnvOr_Good_Fallback(t *testing.T) { + t.Setenv("TEST_ENVVAR_MISSING", "") + assert.Equal(t, "default-value", envOr("TEST_ENVVAR_MISSING", "default-value")) +} + +func TestEnvOr_Good_UnsetUsesFallback(t *testing.T) { + t.Setenv("TEST_ENVVAR_TOTALLY_MISSING", "") + assert.Equal(t, "fallback", envOr("TEST_ENVVAR_TOTALLY_MISSING", "fallback")) +} + +func TestDetectLanguage_Good_Go(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "go.mod"), "module test")) + assert.Equal(t, "go", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_PHP(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "composer.json"), "{}")) + assert.Equal(t, "php", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_TypeScript(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "package.json"), "{}")) + assert.Equal(t, "ts", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_Rust(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "Cargo.toml"), "[package]")) + assert.Equal(t, "rust", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_Python(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "requirements.txt"), "flask")) + assert.Equal(t, "py", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_Cpp(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "CMakeLists.txt"), "cmake_minimum_required")) + assert.Equal(t, "cpp", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_Docker(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "Dockerfile"), "FROM alpine")) + assert.Equal(t, "docker", detectLanguage(dir)) +} + +func TestDetectLanguage_Good_DefaultsToGo(t *testing.T) { + dir := t.TempDir() + assert.Equal(t, "go", detectLanguage(dir)) +} + +func TestDetectBuildCmd_Good(t *testing.T) { + tests := []struct { + file string + content string + expected string + }{ + {"go.mod", "module test", "go build ./..."}, + {"composer.json", "{}", "composer install"}, + {"package.json", "{}", "npm run build"}, + {"requirements.txt", "flask", "pip install -e ."}, + {"Cargo.toml", "[package]", "cargo build"}, + {"CMakeLists.txt", "cmake", "cmake --build ."}, + } + + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, tt.file), tt.content)) + assert.Equal(t, tt.expected, detectBuildCmd(dir)) + }) + } +} + +func TestDetectBuildCmd_Good_DefaultsToGo(t *testing.T) { + dir := t.TempDir() + assert.Equal(t, "go build ./...", detectBuildCmd(dir)) +} + +func TestDetectTestCmd_Good(t *testing.T) { + tests := []struct { + file string + content string + expected string + }{ + {"go.mod", "module test", "go test ./..."}, + {"composer.json", "{}", "composer test"}, + {"package.json", "{}", "npm test"}, + {"requirements.txt", "flask", "pytest"}, + {"Cargo.toml", "[package]", "cargo test"}, + {"CMakeLists.txt", "cmake", "ctest"}, + } + + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, tt.file), tt.content)) + assert.Equal(t, tt.expected, detectTestCmd(dir)) + }) + } +} + +func TestDetectTestCmd_Good_DefaultsToGo(t *testing.T) { + dir := t.TempDir() + assert.Equal(t, "go test ./...", detectTestCmd(dir)) +} + +func TestNewPrep_Good_Defaults(t *testing.T) { + t.Setenv("FORGE_TOKEN", "") + t.Setenv("GITEA_TOKEN", "") + t.Setenv("CORE_BRAIN_KEY", "") + t.Setenv("FORGE_URL", "") + t.Setenv("CORE_BRAIN_URL", "") + t.Setenv("SPECS_PATH", "") + t.Setenv("CODE_PATH", "") + + s := NewPrep() + assert.Equal(t, "https://forge.lthn.ai", s.forgeURL) + assert.Equal(t, "https://api.lthn.sh", s.brainURL) + assert.NotNil(t, s.client) +} + +func TestNewPrep_Good_EnvOverrides(t *testing.T) { + t.Setenv("FORGE_URL", "https://custom-forge.example.com") + t.Setenv("FORGE_TOKEN", "test-token") + t.Setenv("CORE_BRAIN_URL", "https://custom-brain.example.com") + t.Setenv("CORE_BRAIN_KEY", "brain-key-123") + t.Setenv("SPECS_PATH", "/custom/specs") + t.Setenv("CODE_PATH", "/custom/code") + + s := NewPrep() + assert.Equal(t, "https://custom-forge.example.com", s.forgeURL) + assert.Equal(t, "test-token", s.forgeToken) + assert.Equal(t, "https://custom-brain.example.com", s.brainURL) + assert.Equal(t, "brain-key-123", s.brainKey) + assert.Equal(t, "/custom/specs", s.specsPath) + assert.Equal(t, "/custom/code", s.codePath) +} + +func TestNewPrep_Good_GiteaTokenFallback(t *testing.T) { + t.Setenv("FORGE_TOKEN", "") + t.Setenv("GITEA_TOKEN", "gitea-fallback-token") + + s := NewPrep() + assert.Equal(t, "gitea-fallback-token", s.forgeToken) +} + +func TestPrepSubsystem_Good_Name(t *testing.T) { + s := &PrepSubsystem{} + assert.Equal(t, "agentic", s.Name()) +} + +func TestSetCompletionNotifier_Good(t *testing.T) { + s := &PrepSubsystem{} + assert.Nil(t, s.onComplete) + + notifier := &mockNotifier{} + s.SetCompletionNotifier(notifier) + assert.NotNil(t, s.onComplete) +} + +type mockNotifier struct { + poked bool +} + +func (m *mockNotifier) Poke() { + m.poked = true +} diff --git a/pkg/agentic/queue_test.go b/pkg/agentic/queue_test.go new file mode 100644 index 0000000..036801a --- /dev/null +++ b/pkg/agentic/queue_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "path/filepath" + "testing" + + coreio "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBaseAgent_Ugly_Empty(t *testing.T) { + assert.Equal(t, "", baseAgent("")) +} + +func TestBaseAgent_Ugly_MultipleColons(t *testing.T) { + // SplitN with N=2 should only split on first colon + assert.Equal(t, "claude", baseAgent("claude:opus:extra")) +} + +func TestDispatchConfig_Good_Defaults(t *testing.T) { + // loadAgentsConfig falls back to defaults when no config file exists + s := &PrepSubsystem{codePath: t.TempDir()} + t.Setenv("CORE_WORKSPACE", t.TempDir()) + + cfg := s.loadAgentsConfig() + assert.Equal(t, "claude", cfg.Dispatch.DefaultAgent) + assert.Equal(t, "coding", cfg.Dispatch.DefaultTemplate) + assert.Equal(t, 1, cfg.Concurrency["claude"]) + assert.Equal(t, 3, cfg.Concurrency["gemini"]) +} + +func TestCanDispatchAgent_Good_NoConfig(t *testing.T) { + // With no running workspaces and default config, should be able to dispatch + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + require.NoError(t, coreio.Local.EnsureDir(filepath.Join(root, "workspace"))) + + s := &PrepSubsystem{codePath: t.TempDir()} + assert.True(t, s.canDispatchAgent("gemini")) +} + +func TestCanDispatchAgent_Good_UnknownAgent(t *testing.T) { + // Unknown agent has no limit, so always allowed + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + require.NoError(t, coreio.Local.EnsureDir(filepath.Join(root, "workspace"))) + + s := &PrepSubsystem{codePath: t.TempDir()} + assert.True(t, s.canDispatchAgent("unknown-agent")) +} + +func TestCountRunningByAgent_Good_EmptyWorkspace(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + require.NoError(t, coreio.Local.EnsureDir(filepath.Join(root, "workspace"))) + + s := &PrepSubsystem{} + assert.Equal(t, 0, s.countRunningByAgent("gemini")) + assert.Equal(t, 0, s.countRunningByAgent("claude")) +} + +func TestCountRunningByAgent_Good_NoRunning(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + // Create a workspace with completed status under workspace/ + ws := filepath.Join(root, "workspace", "test-ws") + require.NoError(t, coreio.Local.EnsureDir(ws)) + require.NoError(t, writeStatus(ws, &WorkspaceStatus{ + Status: "completed", + Agent: "gemini", + PID: 0, + })) + + s := &PrepSubsystem{} + assert.Equal(t, 0, s.countRunningByAgent("gemini")) +} + +func TestDelayForAgent_Good_NoConfig(t *testing.T) { + // With no config, delay should be 0 + t.Setenv("CORE_WORKSPACE", t.TempDir()) + s := &PrepSubsystem{codePath: t.TempDir()} + assert.Equal(t, 0, int(s.delayForAgent("gemini").Seconds())) +} diff --git a/pkg/agentic/status_test.go b/pkg/agentic/status_test.go new file mode 100644 index 0000000..9c49f9c --- /dev/null +++ b/pkg/agentic/status_test.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" + + coreio "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteStatus_Good(t *testing.T) { + dir := t.TempDir() + status := &WorkspaceStatus{ + Status: "running", + Agent: "gemini", + Repo: "go-io", + Task: "fix tests", + PID: 12345, + StartedAt: time.Now(), + Runs: 1, + } + + err := writeStatus(dir, status) + require.NoError(t, err) + + // Verify file was written via coreio + data, err := coreio.Local.Read(filepath.Join(dir, "status.json")) + require.NoError(t, err) + + var read WorkspaceStatus + err = json.Unmarshal([]byte(data), &read) + require.NoError(t, err) + + assert.Equal(t, "running", read.Status) + assert.Equal(t, "gemini", read.Agent) + assert.Equal(t, "go-io", read.Repo) + assert.Equal(t, "fix tests", read.Task) + assert.Equal(t, 12345, read.PID) + assert.Equal(t, 1, read.Runs) + assert.False(t, read.UpdatedAt.IsZero(), "UpdatedAt should be set by writeStatus") +} + +func TestWriteStatus_Good_UpdatesTimestamp(t *testing.T) { + dir := t.TempDir() + before := time.Now().Add(-time.Second) + + status := &WorkspaceStatus{ + Status: "running", + Agent: "claude", + } + + err := writeStatus(dir, status) + require.NoError(t, err) + + assert.True(t, status.UpdatedAt.After(before), "UpdatedAt should be after the start time") +} + +func TestReadStatus_Good(t *testing.T) { + dir := t.TempDir() + + status := &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + Repo: "go-log", + Task: "add logging", + Branch: "agent/add-logging", + StartedAt: time.Now().Truncate(time.Second), + UpdatedAt: time.Now().Truncate(time.Second), + Runs: 2, + PRURL: "https://forge.lthn.ai/core/go-log/pulls/5", + } + + data, err := json.MarshalIndent(status, "", " ") + require.NoError(t, err) + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "status.json"), string(data))) + + read, err := readStatus(dir) + require.NoError(t, err) + + assert.Equal(t, "completed", read.Status) + assert.Equal(t, "codex", read.Agent) + assert.Equal(t, "go-log", read.Repo) + assert.Equal(t, "add logging", read.Task) + assert.Equal(t, "agent/add-logging", read.Branch) + assert.Equal(t, 2, read.Runs) + assert.Equal(t, "https://forge.lthn.ai/core/go-log/pulls/5", read.PRURL) +} + +func TestReadStatus_Bad_NoFile(t *testing.T) { + dir := t.TempDir() + _, err := readStatus(dir) + assert.Error(t, err) +} + +func TestReadStatus_Bad_InvalidJSON(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "status.json"), "not json{")) + + _, err := readStatus(dir) + assert.Error(t, err) +} + +func TestReadStatus_Good_BlockedWithQuestion(t *testing.T) { + dir := t.TempDir() + + status := &WorkspaceStatus{ + Status: "blocked", + Agent: "gemini", + Repo: "go-io", + Question: "Which interface should I implement?", + } + + data, err := json.MarshalIndent(status, "", " ") + require.NoError(t, err) + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "status.json"), string(data))) + + read, err := readStatus(dir) + require.NoError(t, err) + + assert.Equal(t, "blocked", read.Status) + assert.Equal(t, "Which interface should I implement?", read.Question) +} + +func TestWriteReadStatus_Good_Roundtrip(t *testing.T) { + dir := t.TempDir() + + original := &WorkspaceStatus{ + Status: "running", + Agent: "claude:opus", + Repo: "agent", + Org: "core", + Task: "write tests for agentic package", + Branch: "agent/write-tests", + Issue: 42, + PID: 99999, + StartedAt: time.Now().Truncate(time.Second), + Runs: 3, + } + + err := writeStatus(dir, original) + require.NoError(t, err) + + read, err := readStatus(dir) + require.NoError(t, err) + + assert.Equal(t, original.Status, read.Status) + assert.Equal(t, original.Agent, read.Agent) + assert.Equal(t, original.Repo, read.Repo) + assert.Equal(t, original.Org, read.Org) + assert.Equal(t, original.Task, read.Task) + assert.Equal(t, original.Branch, read.Branch) + assert.Equal(t, original.Issue, read.Issue) + assert.Equal(t, original.PID, read.PID) + assert.Equal(t, original.Runs, read.Runs) +} + +func TestWriteStatus_Good_OverwriteExisting(t *testing.T) { + dir := t.TempDir() + + first := &WorkspaceStatus{Status: "running", Agent: "gemini"} + err := writeStatus(dir, first) + require.NoError(t, err) + + second := &WorkspaceStatus{Status: "completed", Agent: "gemini"} + err = writeStatus(dir, second) + require.NoError(t, err) + + read, err := readStatus(dir) + require.NoError(t, err) + assert.Equal(t, "completed", read.Status) +} + +func TestReadStatus_Ugly_EmptyFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, coreio.Local.Write(filepath.Join(dir, "status.json"), "")) + + _, err := readStatus(dir) + assert.Error(t, err) +}