Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/test-controller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ on:
pull_request:
paths:
- 'agentteams-controller/**'
- 'copaw/**'
- 'plugins/teamharness/**'
- 'plugins/tests/**'
- 'docs/design/teamharness/**'
- '.github/workflows/test-controller.yml'
push:
branches: [main]
paths:
- 'agentteams-controller/**'
- 'copaw/**'
- 'plugins/teamharness/**'
- 'plugins/tests/**'
- 'docs/design/teamharness/**'
workflow_dispatch:

jobs:
Expand All @@ -30,3 +38,36 @@ jobs:
- name: Integration tests (envtest)
working-directory: agentteams-controller
run: make test-integration

teamharness:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'

- name: Install focused Python test dependencies
run: |
python -m pip install \
'agentscope==2.0.4.post1' \
'PyYAML>=6.0,<7' \
'pytest>=8.3,<10' \
'pytest-asyncio>=0.23,<2'

- name: Run durable continuation contract tests
env:
NO_PROXY: 127.0.0.1,localhost
run: |
ruby plugins/tests/teamharness/test-contracts.rb
python -m pytest plugins/tests/teamharness/mcp/test_continuation.py -q
python -m pytest plugins/tests/teamharness/test_pull_project.py -q
PYTHONPATH="${GITHUB_WORKSPACE}/copaw/src" \
python -m pytest copaw/tests/test_taskflow_tool.py -q
ruby plugins/tests/teamharness/mcp/tools/test-taskflow.rb
20 changes: 20 additions & 0 deletions agentteams-controller/cmd/agt/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"

Expand Down Expand Up @@ -101,6 +102,7 @@ func getProjectsCmd() *cobra.Command {
var team string
var mermaid bool
var output string
var includeTasks bool

cmd := &cobra.Command{
Use: "projects [name]",
Expand All @@ -111,14 +113,31 @@ func getProjectsCmd() *cobra.Command {
agt get projects --team alpha-team
agt get projects demo-project-001
agt get projects demo-project-001 -o json
agt get projects demo-project-001 --include-tasks -o json
agt get projects demo-project-001 --mermaid`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client := NewAPIClient()
if includeTasks && len(args) != 1 {
return fmt.Errorf("--include-tasks requires a project name")
}
if includeTasks && output != "json" {
return fmt.Errorf("--include-tasks requires -o json")
}

if len(args) == 1 {
var resp map[string]any
path := "/api/v1/projects/" + args[0] + "/workflow"
query := url.Values{}
if team != "" {
query.Set("team", team)
}
if includeTasks {
query.Set("includeTasks", "true")
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
if err := client.DoJSON("GET", path, nil, &resp); err != nil {
return fmt.Errorf("get project workflow: %w", err)
}
Expand Down Expand Up @@ -181,6 +200,7 @@ func getProjectsCmd() *cobra.Command {

cmd.Flags().StringVar(&team, "team", "", "Filter by team name")
cmd.Flags().BoolVar(&mermaid, "mermaid", false, "Render workflow as a Mermaid flowchart")
cmd.Flags().BoolVar(&includeTasks, "include-tasks", false, "Include raw TaskMeta details for a named project")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)")
return cmd
}
Expand Down
16 changes: 15 additions & 1 deletion agentteams-controller/cmd/agt/project_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"net/url"
"os"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -130,6 +131,8 @@ func projectCreateCmd() *cobra.Command {
func projectCancelCmd() *cobra.Command {
var reason string
var replacement string
var submissionID string
var team string
cmd := &cobra.Command{
Use: "cancel <project-id> <task-id>",
Short: "Cancel a single task (reason required)",
Expand All @@ -142,11 +145,22 @@ func projectCancelCmd() *cobra.Command {
if replacement != "" {
body["replacementTaskId"] = replacement
}
return projectWrite("POST", "/api/v1/projects/"+args[0]+"/tasks/"+args[1]+"/cancel", body)
if submissionID != "" {
body["submissionId"] = submissionID
}
path := "/api/v1/projects/" + args[0] + "/tasks/" + args[1] + "/cancel"
if team != "" {
query := url.Values{}
query.Set("team", team)
path += "?" + query.Encode()
}
return projectWrite("POST", path, body)
},
}
cmd.Flags().StringVar(&reason, "reason", "", "cancellation reason (required)")
cmd.Flags().StringVar(&replacement, "replacement", "", "optional replacement task id")
cmd.Flags().StringVar(&submissionID, "submission-id", "", "current task submission identity (required when TaskMeta has submission_id)")
cmd.Flags().StringVar(&team, "team", "", "owning team for an ambiguous project id")
return cmd
}

Expand Down
93 changes: 93 additions & 0 deletions agentteams-controller/cmd/agt/project_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestProjectCancelCommandForwardsSubmissionID(t *testing.T) {
var requestBody map[string]any
var requestMethod string
var requestPath string
var requestTeam string
var decodeErr error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestMethod = r.Method
requestPath = r.URL.Path
requestTeam = r.URL.Query().Get("team")
decodeErr = json.NewDecoder(r.Body).Decode(&requestBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL)
t.Setenv("AGENTTEAMS_AUTH_TOKEN", "test-token")

cmd := projectCancelCmd()
cmd.SetArgs([]string{
"p1", "t1",
"--reason", "superseded",
"--submission-id", "submission-1",
"--team", "alpha-team",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if decodeErr != nil {
t.Fatalf("decode request: %v", decodeErr)
}
if requestMethod != http.MethodPost || requestPath != "/api/v1/projects/p1/tasks/t1/cancel" {
t.Fatalf("request=%s %s", requestMethod, requestPath)
}
if requestTeam != "alpha-team" {
t.Fatalf("team query=%q, want alpha-team", requestTeam)
}
if requestBody["submissionId"] != "submission-1" {
t.Fatalf("request body=%v, want submissionId", requestBody)
}
}

func TestGetProjectsCommandForwardsIncludeTasks(t *testing.T) {
requested := make(chan *http.Request, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested <- r.Clone(r.Context())
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"project_id":"p1","nodes":[],"next":[],"interrupts":[]}`))
}))
defer server.Close()
t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL)
t.Setenv("AGENTTEAMS_AUTH_TOKEN", "test-token")

cmd := getProjectsCmd()
cmd.SetArgs([]string{"p1", "--include-tasks", "-o", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
req := <-requested
if req.URL.Path != "/api/v1/projects/p1/workflow" || req.URL.Query().Get("includeTasks") != "true" {
t.Fatalf("request URL=%s, want workflow?includeTasks=true", req.URL.String())
}
}

func TestGetProjectsCommandIncludeTasksRequiresJSONOutput(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
t.Setenv("AGENTTEAMS_CONTROLLER_URL", server.URL)

cmd := getProjectsCmd()
cmd.SetArgs([]string{"p1", "--include-tasks"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--include-tasks requires -o json") {
t.Fatalf("error=%v, want JSON output requirement", err)
}
if requests != 0 {
t.Fatalf("requests=%d, want no request for invalid flag combination", requests)
}
}
Loading
Loading