diff --git a/pkg/agentic/remote_dispatch_test.go b/pkg/agentic/remote_dispatch_test.go index 95532f3..d89ea84 100644 --- a/pkg/agentic/remote_dispatch_test.go +++ b/pkg/agentic/remote_dispatch_test.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 +// Tests for remote.go — dispatchRemote, resolveHost, remoteToken. + package agentic import ( @@ -17,58 +19,18 @@ import ( // --- dispatchRemote --- -func TestDispatchRemote_Bad_MissingHost(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ - Repo: "go-io", Task: "do it", - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), "host is required") -} - -func TestDispatchRemote_Bad_MissingRepo(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ - Host: "charon", Task: "do it", - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), "repo is required") -} - -func TestDispatchRemote_Bad_MissingTask(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ - Host: "charon", Repo: "go-io", - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), "task is required") -} - -func TestDispatchRemote_Good_FullRoundtrip(t *testing.T) { +func TestRemote_DispatchRemote_Good(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Mcp-Session-Id", "test-session") w.Header().Set("Content-Type", "text/event-stream") - switch callCount { case 1: - // Initialize response fmt.Fprintf(w, "data: {\"result\":{}}\n\n") case 2: - // Initialized notification — just accept w.WriteHeader(200) case 3: - // Tool call response result := map[string]any{ "result": map[string]any{ "content": []map[string]any{ @@ -82,105 +44,66 @@ func TestDispatchRemote_Good_FullRoundtrip(t *testing.T) { })) t.Cleanup(srv.Close) - // Override resolveHost to use our test server - t.Setenv("AGENT_TOKEN_TESTHOST", "test-token") - - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ - Host: srv.Listener.Addr().String(), - Repo: "go-io", - Task: "Fix tests", + Host: srv.Listener.Addr().String(), Repo: "go-io", Task: "Fix tests", }) require.NoError(t, err) assert.True(t, out.Success) assert.Equal(t, "go-io", out.Repo) } -func TestDispatchRemote_Bad_InitFails(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - })) +func TestRemote_DispatchRemote_Bad(t *testing.T) { + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + + // Missing host + _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{Repo: "go-io", Task: "do"}) + assert.Contains(t, err.Error(), "host is required") + + // Missing repo + _, _, err = s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{Host: "charon", Task: "do"}) + assert.Contains(t, err.Error(), "repo is required") + + // Missing task + _, _, err = s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{Host: "charon", Repo: "go-io"}) + assert.Contains(t, err.Error(), "task is required") + + // Init fails (server error) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) })) t.Cleanup(srv.Close) - - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - - _, _, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ - Host: srv.Listener.Addr().String(), - Repo: "go-io", - Task: "test", + _, _, err = s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ + Host: srv.Listener.Addr().String(), Repo: "go-io", Task: "test", }) - assert.Error(t, err) assert.Contains(t, err.Error(), "MCP initialize failed") } -// --- statusRemote --- - -func TestStatusRemote_Bad_MissingHost(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "host is required") -} - -func TestStatusRemote_Good_Unreachable(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - // Use a port that's not listening - _, out, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{ - Host: "127.0.0.1:1", - }) - assert.NoError(t, err) // returns output, not error - assert.Contains(t, out.Error, "unreachable") -} - -func TestStatusRemote_Good_FullRoundtrip(t *testing.T) { +func TestRemote_DispatchRemote_Ugly(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ - w.Header().Set("Mcp-Session-Id", "test-session") + w.Header().Set("Mcp-Session-Id", "s") w.Header().Set("Content-Type", "text/event-stream") - switch callCount { case 1: fmt.Fprintf(w, "data: {\"result\":{}}\n\n") case 2: w.WriteHeader(200) case 3: - result := map[string]any{ - "result": map[string]any{ - "content": []map[string]any{ - {"text": `{"total":5,"running":2,"completed":3,"failed":0}`}, - }, - }, - } + // JSON-RPC error response + result := map[string]any{"error": map[string]any{"message": "tool not found"}} data, _ := json.Marshal(result) fmt.Fprintf(w, "data: %s\n\n", data) } })) t.Cleanup(srv.Close) - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - - _, out, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{ - Host: srv.Listener.Addr().String(), + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + _, out, err := s.dispatchRemote(context.Background(), nil, RemoteDispatchInput{ + Host: srv.Listener.Addr().String(), Repo: "go-io", Task: "test", + Agent: "claude:opus", Org: "core", Template: "coding", Persona: "eng", + Variables: map[string]string{"key": "val"}, }) require.NoError(t, err) - assert.True(t, out.Success) - assert.Equal(t, 5, out.Stats.Total) - assert.Equal(t, 2, out.Stats.Running) + assert.False(t, out.Success) + assert.Contains(t, out.Error, "tool not found") } diff --git a/pkg/agentic/remote_status_test.go b/pkg/agentic/remote_status_test.go new file mode 100644 index 0000000..a2a3757 --- /dev/null +++ b/pkg/agentic/remote_status_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Tests for remote_status.go — statusRemote. + +package agentic + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- statusRemote --- + +func TestRemoteStatus_StatusRemote_Good(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Mcp-Session-Id", "s") + w.Header().Set("Content-Type", "text/event-stream") + switch callCount { + case 1: + fmt.Fprintf(w, "data: {\"result\":{}}\n\n") + case 2: + w.WriteHeader(200) + case 3: + result := map[string]any{ + "result": map[string]any{ + "content": []map[string]any{ + {"text": `{"total":5,"running":2,"completed":3,"failed":0}`}, + }, + }, + } + data, _ := json.Marshal(result) + fmt.Fprintf(w, "data: %s\n\n", data) + } + })) + t.Cleanup(srv.Close) + + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + _, out, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{ + Host: srv.Listener.Addr().String(), + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, 5, out.Stats.Total) + assert.Equal(t, 2, out.Stats.Running) +} + +func TestRemoteStatus_StatusRemote_Bad(t *testing.T) { + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + + // Missing host + _, _, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "host is required") + + // Unreachable + _, out, err := s.statusRemote(context.Background(), nil, RemoteStatusInput{Host: "127.0.0.1:1"}) + assert.NoError(t, err) + assert.Contains(t, out.Error, "unreachable") + + // Call fails after init + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Mcp-Session-Id", "s") + w.Header().Set("Content-Type", "text/event-stream") + switch callCount { + case 1: + fmt.Fprintf(w, "data: {\"result\":{}}\n\n") + case 2: + w.WriteHeader(200) + case 3: + w.WriteHeader(500) + } + })) + t.Cleanup(srv.Close) + + _, out2, _ := s.statusRemote(context.Background(), nil, RemoteStatusInput{Host: srv.Listener.Addr().String()}) + assert.Contains(t, out2.Error, "call failed") +} + +func TestRemoteStatus_StatusRemote_Ugly(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Mcp-Session-Id", "s") + w.Header().Set("Content-Type", "text/event-stream") + switch callCount { + case 1: + fmt.Fprintf(w, "data: {\"result\":{}}\n\n") + case 2: + w.WriteHeader(200) + case 3: + // JSON-RPC error + result := map[string]any{"error": map[string]any{"code": -32000, "message": "internal error"}} + data, _ := json.Marshal(result) + fmt.Fprintf(w, "data: %s\n\n", data) + } + })) + t.Cleanup(srv.Close) + + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + _, out, _ := s.statusRemote(context.Background(), nil, RemoteStatusInput{Host: srv.Listener.Addr().String()}) + assert.False(t, out.Success) + assert.Contains(t, out.Error, "internal error") + + // Unparseable response + callCount2 := 0 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount2++ + w.Header().Set("Mcp-Session-Id", "s") + w.Header().Set("Content-Type", "text/event-stream") + switch callCount2 { + case 1: + fmt.Fprintf(w, "data: {\"result\":{}}\n\n") + case 2: + w.WriteHeader(200) + case 3: + fmt.Fprintf(w, "data: not-json\n\n") + } + })) + t.Cleanup(srv2.Close) + + _, out2, _ := s.statusRemote(context.Background(), nil, RemoteStatusInput{Host: srv2.Listener.Addr().String()}) + assert.False(t, out2.Success) + assert.Contains(t, out2.Error, "failed to parse") +} diff --git a/pkg/agentic/resume_test.go b/pkg/agentic/resume_test.go index adaa80c..75160d1 100644 --- a/pkg/agentic/resume_test.go +++ b/pkg/agentic/resume_test.go @@ -17,83 +17,23 @@ import ( // --- resume --- -func TestResume_Bad_EmptyWorkspace(t *testing.T) { - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.resume(context.Background(), nil, ResumeInput{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "workspace is required") -} - -func TestResume_Bad_WorkspaceNotFound(t *testing.T) { - dir := t.TempDir() - t.Setenv("DIR_HOME", dir) - - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.resume(context.Background(), nil, ResumeInput{Workspace: "nonexistent"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "workspace not found") -} - -func TestResume_Bad_NotResumableStatus(t *testing.T) { - dir := t.TempDir() - t.Setenv("DIR_HOME", dir) - - wsRoot := WorkspaceRoot() - ws := filepath.Join(wsRoot, "ws-running") - repoDir := filepath.Join(ws, "repo") - os.MkdirAll(repoDir, 0o755) - - // Init git repo - exec.Command("git", "init", repoDir).Run() - - st := &WorkspaceStatus{Status: "running", Repo: "test", Agent: "codex"} - data, _ := json.Marshal(st) - os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) - - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, _, err := s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-running"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not resumable") -} - -func TestResume_Good_DryRun(t *testing.T) { - dir := t.TempDir() - t.Setenv("DIR_HOME", dir) +func TestResume_Resume_Good(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) wsRoot := WorkspaceRoot() ws := filepath.Join(wsRoot, "ws-blocked") repoDir := filepath.Join(ws, "repo") os.MkdirAll(repoDir, 0o755) - - // Init git repo exec.Command("git", "init", repoDir).Run() - st := &WorkspaceStatus{ - Status: "blocked", - Repo: "go-io", - Agent: "codex", - Task: "Fix the tests", - } + st := &WorkspaceStatus{Status: "blocked", Repo: "go-io", Agent: "codex", Task: "Fix the tests"} data, _ := json.Marshal(st) os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} _, out, err := s.resume(context.Background(), nil, ResumeInput{ - Workspace: "ws-blocked", - Answer: "Use the new Core API", - DryRun: true, + Workspace: "ws-blocked", Answer: "Use the new Core API", DryRun: true, }) require.NoError(t, err) assert.True(t, out.Success) @@ -102,40 +42,80 @@ func TestResume_Good_DryRun(t *testing.T) { assert.Contains(t, out.Prompt, "Fix the tests") assert.Contains(t, out.Prompt, "Use the new Core API") - // Verify ANSWER.md was written - answerContent, readErr := os.ReadFile(filepath.Join(repoDir, "ANSWER.md")) - require.NoError(t, readErr) + answerContent, _ := os.ReadFile(filepath.Join(repoDir, "ANSWER.md")) assert.Contains(t, string(answerContent), "Use the new Core API") + + // Agent override + _, out2, _ := s.resume(context.Background(), nil, ResumeInput{ + Workspace: "ws-blocked", Agent: "claude:opus", DryRun: true, + }) + assert.Equal(t, "claude:opus", out2.Agent) + + // Completed workspace is resumable too + ws2 := filepath.Join(wsRoot, "ws-done") + os.MkdirAll(filepath.Join(ws2, "repo"), 0o755) + exec.Command("git", "init", filepath.Join(ws2, "repo")).Run() + st2 := &WorkspaceStatus{Status: "completed", Repo: "go-io", Agent: "codex", Task: "Review code"} + data2, _ := json.Marshal(st2) + os.WriteFile(filepath.Join(ws2, "status.json"), data2, 0o644) + + _, out3, err3 := s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-done", DryRun: true}) + require.NoError(t, err3) + assert.True(t, out3.Success) } -func TestResume_Good_AgentOverride(t *testing.T) { - dir := t.TempDir() - t.Setenv("DIR_HOME", dir) +func TestResume_Resume_Bad(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) - wsRoot := WorkspaceRoot() - ws := filepath.Join(wsRoot, "ws-failed") - repoDir := filepath.Join(ws, "repo") - os.MkdirAll(repoDir, 0o755) - exec.Command("git", "init", repoDir).Run() + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} - st := &WorkspaceStatus{ - Status: "failed", - Repo: "go-crypt", - Agent: "codex", - Task: "Review code", - } + // Empty workspace + _, _, err := s.resume(context.Background(), nil, ResumeInput{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "workspace is required") + + // Workspace not found + _, _, err = s.resume(context.Background(), nil, ResumeInput{Workspace: "nonexistent"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "workspace not found") + + // Not resumable (running) + ws := filepath.Join(WorkspaceRoot(), "ws-running") + os.MkdirAll(filepath.Join(ws, "repo"), 0o755) + exec.Command("git", "init", filepath.Join(ws, "repo")).Run() + st := &WorkspaceStatus{Status: "running", Repo: "test", Agent: "codex"} data, _ := json.Marshal(st) os.WriteFile(filepath.Join(ws, "status.json"), data, 0o644) - s := &PrepSubsystem{ - backoff: make(map[string]time.Time), - failCount: make(map[string]int), - } - _, out, err := s.resume(context.Background(), nil, ResumeInput{ - Workspace: "ws-failed", - Agent: "claude:opus", - DryRun: true, - }) - require.NoError(t, err) - assert.Equal(t, "claude:opus", out.Agent, "should override agent") + _, _, err = s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-running"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not resumable") +} + +func TestResume_Resume_Ugly(t *testing.T) { + root := t.TempDir() + t.Setenv("CORE_WORKSPACE", root) + + // Workspace exists but no status.json + ws := filepath.Join(WorkspaceRoot(), "ws-nostatus") + os.MkdirAll(filepath.Join(ws, "repo"), 0o755) + exec.Command("git", "init", filepath.Join(ws, "repo")).Run() + + s := &PrepSubsystem{backoff: make(map[string]time.Time), failCount: make(map[string]int)} + _, _, err := s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-nostatus"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no status.json") + + // No answer provided — prompt has no ANSWER section + ws2 := filepath.Join(WorkspaceRoot(), "ws-noanswer") + os.MkdirAll(filepath.Join(ws2, "repo"), 0o755) + exec.Command("git", "init", filepath.Join(ws2, "repo")).Run() + st := &WorkspaceStatus{Status: "blocked", Repo: "test", Agent: "codex", Task: "Fix"} + data, _ := json.Marshal(st) + os.WriteFile(filepath.Join(ws2, "status.json"), data, 0o644) + + _, out, err := s.resume(context.Background(), nil, ResumeInput{Workspace: "ws-noanswer", DryRun: true}) + require.NoError(t, err) + assert.NotContains(t, out.Prompt, "ANSWER TO YOUR QUESTION") }