355 lines
12 KiB
Go
355 lines
12 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"dappco.re/go/agent/pkg/lib"
|
|
core "dappco.re/go/core"
|
|
"dappco.re/go/core/forge"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestActions_HandleDispatch_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleDispatch(context.Background(), core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "task", Value: "fix tests"},
|
|
))
|
|
// Will fail (no local clone) but exercises the handler path
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleDispatch_Bad_EntitlementDenied(t *testing.T) {
|
|
c := core.New(core.WithService(ProcessRegister))
|
|
c.ServiceStartup(context.Background(), nil)
|
|
c.SetEntitlementChecker(func(action string, _ int, _ context.Context) core.Entitlement {
|
|
if action == "agentic.concurrency" {
|
|
return core.Entitlement{Allowed: false, Reason: "dispatch limit reached"}
|
|
}
|
|
return core.Entitlement{Allowed: true, Unlimited: true}
|
|
})
|
|
|
|
s := &PrepSubsystem{
|
|
ServiceRuntime: core.NewServiceRuntime(c, AgentOptions{}),
|
|
backoff: make(map[string]time.Time),
|
|
failCount: make(map[string]int),
|
|
}
|
|
r := s.handleDispatch(context.Background(), core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "task", Value: "fix tests"},
|
|
))
|
|
|
|
assert.False(t, r.OK)
|
|
err, ok := r.Value.(error)
|
|
require.True(t, ok)
|
|
assert.Contains(t, err.Error(), "dispatch limit reached")
|
|
}
|
|
|
|
func TestActions_HandleDispatch_Good_RecordsUsage(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("CORE_WORKSPACE", root)
|
|
t.Setenv("CORE_BRAIN_KEY", "")
|
|
|
|
forgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{
|
|
"title": "Issue",
|
|
"body": "Fix",
|
|
})))
|
|
}))
|
|
t.Cleanup(forgeSrv.Close)
|
|
|
|
s := newPrepWithProcess()
|
|
s.Core().SetEntitlementChecker(func(_ string, _ int, _ context.Context) core.Entitlement {
|
|
return core.Entitlement{Allowed: true, Unlimited: true}
|
|
})
|
|
|
|
srcRepo := core.JoinPath(t.TempDir(), "core", "go-io")
|
|
require.True(t, fs.EnsureDir(srcRepo).OK)
|
|
process := s.Core().Process()
|
|
require.True(t, process.RunIn(context.Background(), srcRepo, "git", "init", "-b", "main").OK)
|
|
require.True(t, process.RunIn(context.Background(), srcRepo, "git", "config", "user.name", "Test").OK)
|
|
require.True(t, process.RunIn(context.Background(), srcRepo, "git", "config", "user.email", "test@test.com").OK)
|
|
require.True(t, fs.Write(core.JoinPath(srcRepo, "go.mod"), "module test\ngo 1.22\n").OK)
|
|
require.True(t, fs.Write(core.JoinPath(srcRepo, "README.md"), "hello\n").OK)
|
|
require.True(t, process.RunIn(context.Background(), srcRepo, "git", "add", ".").OK)
|
|
require.True(t, process.RunIn(
|
|
context.Background(),
|
|
srcRepo,
|
|
"git",
|
|
"commit",
|
|
"-m", "initial commit",
|
|
).OK)
|
|
|
|
recorded := 0
|
|
s.Core().SetUsageRecorder(func(action string, qty int, _ context.Context) {
|
|
if action == "agentic.dispatch" && qty == 1 {
|
|
recorded++
|
|
}
|
|
})
|
|
|
|
s.forge = forge.NewForge(forgeSrv.URL, "tok")
|
|
s.codePath = core.PathDir(core.PathDir(srcRepo))
|
|
|
|
r := s.handleDispatch(context.Background(), core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "issue", Value: 42},
|
|
core.Option{Key: "task", Value: "fix tests"},
|
|
core.Option{Key: "dry-run", Value: true},
|
|
))
|
|
|
|
require.Truef(t, r.OK, "dispatch failed: %#v", r.Value)
|
|
assert.Equal(t, 1, recorded)
|
|
}
|
|
|
|
func TestActions_HandleStatus_Good(t *testing.T) {
|
|
t.Setenv("CORE_WORKSPACE", t.TempDir())
|
|
s := newPrepWithProcess()
|
|
r := s.handleStatus(context.Background(), core.NewOptions())
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandlePrompt_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handlePrompt(context.Background(), core.NewOptions(
|
|
core.Option{Key: "slug", Value: "coding"},
|
|
))
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandlePrompt_Bad(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handlePrompt(context.Background(), core.NewOptions(
|
|
core.Option{Key: "slug", Value: "does-not-exist"},
|
|
))
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleTask_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleTask(context.Background(), core.NewOptions(
|
|
core.Option{Key: "slug", Value: "bug-fix"},
|
|
))
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleFlow_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleFlow(context.Background(), core.NewOptions(
|
|
core.Option{Key: "slug", Value: "go"},
|
|
))
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandlePersona_Good(t *testing.T) {
|
|
personas := lib.ListPersonas()
|
|
if len(personas) == 0 {
|
|
t.Skip("no personas embedded")
|
|
}
|
|
|
|
s := newPrepWithProcess()
|
|
r := s.handlePersona(context.Background(), core.NewOptions(
|
|
core.Option{Key: "path", Value: personas[0]},
|
|
))
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandlePoke_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
s.pokeCh = make(chan struct{}, 1)
|
|
r := s.handlePoke(context.Background(), core.NewOptions())
|
|
assert.True(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandlePoke_Good_DelegatesToRunner(t *testing.T) {
|
|
called := false
|
|
c := core.New()
|
|
c.Action("runner.poke", func(_ context.Context, _ core.Options) core.Result {
|
|
called = true
|
|
return core.Result{OK: true}
|
|
})
|
|
|
|
s := NewPrep()
|
|
s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{})
|
|
|
|
r := s.handlePoke(context.Background(), core.NewOptions())
|
|
require.True(t, r.OK)
|
|
assert.True(t, called)
|
|
}
|
|
|
|
func TestActions_HandleQA_Bad_NoWorkspace(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleQA(context.Background(), core.NewOptions())
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleVerify_Bad_NoWorkspace(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleVerify(context.Background(), core.NewOptions())
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleIngest_Bad_NoWorkspace(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleIngest(context.Background(), core.NewOptions())
|
|
assert.False(t, r.OK)
|
|
}
|
|
|
|
func TestActions_HandleWorkspaceQuery_Good(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
s.workspaces = core.NewRegistry[*WorkspaceStatus]()
|
|
s.workspaces.Set("core/go-io/task-42", &WorkspaceStatus{Status: "blocked", Repo: "go-io"})
|
|
|
|
r := s.handleWorkspaceQuery(nil, WorkspaceQuery{Status: "blocked"})
|
|
require.True(t, r.OK)
|
|
|
|
names, ok := r.Value.([]string)
|
|
require.True(t, ok)
|
|
require.Len(t, names, 1)
|
|
assert.Equal(t, "core/go-io/task-42", names[0])
|
|
}
|
|
|
|
func TestActions_HandleWorkspaceQuery_Bad(t *testing.T) {
|
|
s := newPrepWithProcess()
|
|
r := s.handleWorkspaceQuery(nil, "not-a-workspace-query")
|
|
assert.False(t, r.OK)
|
|
assert.Nil(t, r.Value)
|
|
}
|
|
|
|
func TestActions_DispatchInputFromOptions_Good_MapsRFCFields(t *testing.T) {
|
|
input := dispatchInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "org", Value: "core"},
|
|
core.Option{Key: "task", Value: "Fix the failing tests"},
|
|
core.Option{Key: "agent", Value: "codex:gpt-5.4"},
|
|
core.Option{Key: "template", Value: "coding"},
|
|
core.Option{Key: "plan_template", Value: "bug-fix"},
|
|
core.Option{Key: "variables", Value: map[string]any{"ISSUE": 42, "MODE": "deep"}},
|
|
core.Option{Key: "persona", Value: "code/reviewer"},
|
|
core.Option{Key: "issue", Value: "42"},
|
|
core.Option{Key: "pr", Value: 7},
|
|
core.Option{Key: "branch", Value: "agent/fix-tests"},
|
|
core.Option{Key: "tag", Value: "v0.8.0"},
|
|
core.Option{Key: "dry-run", Value: "true"},
|
|
))
|
|
|
|
assert.Equal(t, "go-io", input.Repo)
|
|
assert.Equal(t, "core", input.Org)
|
|
assert.Equal(t, "Fix the failing tests", input.Task)
|
|
assert.Equal(t, "codex:gpt-5.4", input.Agent)
|
|
assert.Equal(t, "coding", input.Template)
|
|
assert.Equal(t, "bug-fix", input.PlanTemplate)
|
|
assert.Equal(t, map[string]string{"ISSUE": "42", "MODE": "deep"}, input.Variables)
|
|
assert.Equal(t, "code/reviewer", input.Persona)
|
|
assert.Equal(t, 42, input.Issue)
|
|
assert.Equal(t, 7, input.PR)
|
|
assert.Equal(t, "agent/fix-tests", input.Branch)
|
|
assert.Equal(t, "v0.8.0", input.Tag)
|
|
assert.True(t, input.DryRun)
|
|
}
|
|
|
|
func TestActions_PrepInputFromOptions_Good_MapsRFCFields(t *testing.T) {
|
|
input := prepInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-scm"},
|
|
core.Option{Key: "org", Value: "core"},
|
|
core.Option{Key: "task", Value: "Prepare release branch"},
|
|
core.Option{Key: "agent", Value: "claude"},
|
|
core.Option{Key: "issue", Value: 12},
|
|
core.Option{Key: "branch", Value: "dev"},
|
|
core.Option{Key: "template", Value: "security"},
|
|
core.Option{Key: "plan-template", Value: "release"},
|
|
core.Option{Key: "variables", Value: "{\"REPO\":\"go-scm\",\"MODE\":\"resume\"}"},
|
|
core.Option{Key: "persona", Value: "code/security"},
|
|
core.Option{Key: "dry_run", Value: true},
|
|
))
|
|
|
|
assert.Equal(t, "go-scm", input.Repo)
|
|
assert.Equal(t, "core", input.Org)
|
|
assert.Equal(t, "Prepare release branch", input.Task)
|
|
assert.Equal(t, "claude", input.Agent)
|
|
assert.Equal(t, 12, input.Issue)
|
|
assert.Equal(t, "dev", input.Branch)
|
|
assert.Equal(t, "security", input.Template)
|
|
assert.Equal(t, "release", input.PlanTemplate)
|
|
assert.Equal(t, map[string]string{"REPO": "go-scm", "MODE": "resume"}, input.Variables)
|
|
assert.Equal(t, "code/security", input.Persona)
|
|
assert.True(t, input.DryRun)
|
|
}
|
|
|
|
func TestActions_WatchInputFromOptions_Good_ParsesWorkspaceList(t *testing.T) {
|
|
input := watchInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "workspaces", Value: []any{"core/go-io/task-5", " core/go-scm/task-6 "}},
|
|
core.Option{Key: "poll-interval", Value: "15"},
|
|
core.Option{Key: "timeout", Value: "900"},
|
|
))
|
|
|
|
assert.Equal(t, []string{"core/go-io/task-5", "core/go-scm/task-6"}, input.Workspaces)
|
|
assert.Equal(t, 15, input.PollInterval)
|
|
assert.Equal(t, 900, input.Timeout)
|
|
}
|
|
|
|
func TestActions_ReviewQueueInputFromOptions_Good_MapsLocalOnly(t *testing.T) {
|
|
input := reviewQueueInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "limit", Value: "4"},
|
|
core.Option{Key: "reviewer", Value: "both"},
|
|
core.Option{Key: "dry_run", Value: true},
|
|
core.Option{Key: "local_only", Value: "yes"},
|
|
))
|
|
|
|
assert.Equal(t, 4, input.Limit)
|
|
assert.Equal(t, "both", input.Reviewer)
|
|
assert.True(t, input.DryRun)
|
|
assert.True(t, input.LocalOnly)
|
|
}
|
|
|
|
func TestActions_EpicInputFromOptions_Good_ParsesListFields(t *testing.T) {
|
|
input := epicInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "org", Value: "core"},
|
|
core.Option{Key: "title", Value: "AX RFC follow-up"},
|
|
core.Option{Key: "body", Value: "Finish the remaining wrappers"},
|
|
core.Option{Key: "tasks", Value: "[\"Map action inputs\",\"Add tests\"]"},
|
|
core.Option{Key: "labels", Value: "agentic, ax"},
|
|
core.Option{Key: "dispatch", Value: "true"},
|
|
core.Option{Key: "agent", Value: "codex"},
|
|
core.Option{Key: "template", Value: "coding"},
|
|
))
|
|
|
|
assert.Equal(t, "go-io", input.Repo)
|
|
assert.Equal(t, "core", input.Org)
|
|
assert.Equal(t, "AX RFC follow-up", input.Title)
|
|
assert.Equal(t, "Finish the remaining wrappers", input.Body)
|
|
assert.Equal(t, []string{"Map action inputs", "Add tests"}, input.Tasks)
|
|
assert.Equal(t, []string{"agentic", "ax"}, input.Labels)
|
|
assert.True(t, input.Dispatch)
|
|
assert.Equal(t, "codex", input.Agent)
|
|
assert.Equal(t, "coding", input.Template)
|
|
}
|
|
|
|
func TestActions_NormaliseForgeActionOptions_Good_MapsRepoAndNumber(t *testing.T) {
|
|
options := normaliseForgeActionOptions(core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "number", Value: 12},
|
|
core.Option{Key: "title", Value: "Fix watcher"},
|
|
))
|
|
|
|
assert.Equal(t, "go-io", options.String("_arg"))
|
|
assert.Equal(t, "12", options.String("number"))
|
|
assert.Equal(t, "Fix watcher", options.String("title"))
|
|
}
|
|
|
|
func TestActions_OptionHelpers_Ugly_IgnoreMalformedMapJSON(t *testing.T) {
|
|
input := dispatchInputFromOptions(core.NewOptions(
|
|
core.Option{Key: "repo", Value: "go-io"},
|
|
core.Option{Key: "task", Value: "Review"},
|
|
core.Option{Key: "variables", Value: "{\"BROKEN\""},
|
|
))
|
|
|
|
assert.Nil(t, input.Variables)
|
|
}
|