Files
Agent-Squad/cmd/agent-squad/server/server_test.go
T
weichenandCursor a182d7662b Move cross-library squad code into adapter modules
Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients.
Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test.
Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:40:58 +00:00

332 lines
9.8 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"agent-squad/squad"
"agent-squad/store"
"error-code-system/httpx"
"logging-system/logsys"
)
type scriptBackend struct{ script map[string]map[string]string }
func (b *scriptBackend) Name() string { return "script" }
func (b *scriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, t2 := range req.Targets {
content := "\n"
if c, ok := files[t2]; ok {
content = c
}
out := filepath.Join(req.WorkDir, t2)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t2] = []byte(content)
}
return &squad.ExecResult{Success: true, Patches: patches}, nil
}
const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n"
func newTestServer(t *testing.T) (*httptest.Server, *squad.SquadEngine) {
t.Helper()
dir := t.TempDir()
st, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
_ = st.LoadRoles(reg)
bps := squad.NewBlueprintRegistry()
_ = st.LoadBlueprints(bps)
backends := squad.NewBackendResolver()
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{"k":"v"}`},
"02_writer": {"output.md": goodReport},
}
backends.RegisterInstance("script", &scriptBackend{script: script})
sink := squad.NewFanoutSink(1024)
engine, err := squad.NewEngine(squad.Options{DataDir: dir, Registry: reg, Blueprints: bps,
Planner: &squad.MockPlanner{Script: []squad.StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
}}, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(New(engine, sink, st).Handler())
t.Cleanup(ts.Close)
return ts, engine
}
func doJSON(t *testing.T, method, url string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, url, &buf)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out := map[string]any{}
if strings.Contains(resp.Header.Get("Content-Type"), "json") {
_ = json.NewDecoder(resp.Body).Decode(&out)
}
return resp.StatusCode, out
}
func TestRolesCRUDAndPersist(t *testing.T) {
ts, _ := newTestServer(t)
code, out := doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{
"role": "researcher", "name": "调研专员", "backend": "script", "keywords": []string{"调研"},
})
if code != 200 {
t.Fatalf("create role: %d %v", code, out)
}
code, _ = doJSON(t, "GET", ts.URL+"/v1/roles", nil)
if code != 200 {
t.Fatalf("list roles: %d", code)
}
// list 是数组:直接解码判定。
resp, _ := http.Get(ts.URL + "/v1/roles")
var roles []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&roles)
resp.Body.Close()
if len(roles) != 1 || roles[0]["role"] != "researcher" {
t.Fatalf("role not listed: %v", roles)
}
code, _ = doJSON(t, "DELETE", ts.URL+"/v1/roles/researcher", nil)
if code != 200 {
t.Fatalf("delete role: %d", code)
}
code, _ = doJSON(t, "DELETE", ts.URL+"/v1/roles/researcher", nil)
if code != 404 {
t.Fatalf("double delete want 404: %d", code)
}
}
func TestRunPendingRespondViaAPI(t *testing.T) {
ts, _ := newTestServer(t)
_, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": "researcher", "backend": "script"})
_, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": "writer", "backend": "script"})
code, out := doJSON(t, "POST", ts.URL+"/v1/runs", map[string]any{
"goal": map[string]any{"text": "生成报告"},
})
if code != 200 {
t.Fatalf("create run: %d %v", code, out)
}
runID, _ := out["run_id"].(string)
status, _ := out["status"].(string)
if runID == "" || status != string(squad.RunCompleted) {
t.Fatalf("run want completed, got %v", out)
}
code, insp := doJSON(t, "GET", ts.URL+"/v1/runs/"+runID, nil)
if code != 200 || insp["deliverable"] != "output.md" {
t.Fatalf("inspect: %d %v", code, insp)
}
code, bb := doJSON(t, "GET", ts.URL+"/v1/runs/"+runID+"/blackboard", nil)
if code != 200 || bb["fields"] == nil {
t.Fatalf("blackboard: %d %v", code, bb)
}
code, runs := doJSON(t, "GET", ts.URL+"/v1/runs", nil)
if code != 200 {
t.Fatalf("list runs: %d", code)
}
_ = runs
// SSE:应能收到已缓冲事件(回放异步推送,带超时循环读取)。
req, _ := http.NewRequest("GET", ts.URL+"/v1/runs/"+runID+"/events", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("SSE content type: %s", ct)
}
head := collectSSEReadable(t, resp)
if !strings.Contains(head, "run_completed") && !strings.Contains(head, "run_started") {
t.Fatalf("SSE replay missing events: %q", head)
}
}
// collectSSEReadable 持续读取流内容直到出现 run_completed/超时(回放帧异步到达)。
func collectSSEReadable(t *testing.T, resp *http.Response) string {
t.Helper()
done := make(chan string, 1)
go func() {
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if s := sb.String(); strings.Contains(s, "run_completed") || strings.Contains(s, "run_failed") {
done <- s
return
}
}
if err != nil {
done <- sb.String()
return
}
}
}()
select {
case s := <-done:
return s
case <-time.After(3 * time.Second):
t.Fatal("SSE stream did not deliver terminal event in time")
return ""
}
}
type memSink struct {
mu sync.Mutex
entries []logsys.Entry
}
func (m *memSink) Write(e logsys.Entry) error {
m.mu.Lock()
m.entries = append(m.entries, e)
m.mu.Unlock()
return nil
}
func (m *memSink) Sync() error { return nil }
func (m *memSink) Close() error { return nil }
func (m *memSink) has(traceID, code string) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, e := range m.entries {
if e.TraceID == traceID && e.Code == code {
return true
}
}
return false
}
func TestErrorEnvelopeAndTrace(t *testing.T) {
dir := t.TempDir()
st, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
bps := squad.NewBlueprintRegistry()
backends := squad.NewBackendResolver()
sink := squad.NewFanoutSink(16)
engine, err := squad.NewEngine(squad.Options{DataDir: dir, Registry: reg, Blueprints: bps, Backends: backends})
if err != nil {
t.Fatal(err)
}
mem := &memSink{}
logger := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{mem}})
srv := New(engine, sink, st)
srv.SetLogger(logger)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/runs", strings.NewReader(`{"goal":{}}`))
if err != nil {
t.Fatal(err)
}
const traceID = "trace-test-1234"
req.Header.Set(httpx.TraceHeader, traceID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status %d", resp.StatusCode)
}
if got := resp.Header.Get(httpx.TraceHeader); got != traceID {
t.Fatalf("header trace %q", got)
}
var body struct {
Error struct {
Code string `json:"code"`
TraceID string `json:"trace_id"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Error.Code != "squad.goal_required" || body.Error.TraceID != traceID {
t.Fatalf("envelope %+v", body.Error)
}
_ = logger.Sync()
if !mem.has(traceID, "squad.goal_required") {
t.Fatalf("log missing trace/code: %+v", mem.entries)
}
}
func TestHealthAndBlueprints(t *testing.T) {
ts, _ := newTestServer(t)
code, out := doJSON(t, "GET", ts.URL+"/v1/health", nil)
if code != 200 || out["ok"] != true {
t.Fatalf("health: %d %v", code, out)
}
code, _ = doJSON(t, "POST", ts.URL+"/v1/blueprints", map[string]any{
"key": "r", "title": "报告", "steps": []map[string]any{{"role": "writer", "title": "成文"}},
})
if code != 201 {
t.Fatal("blueprint create failed")
}
resp, _ := http.Get(ts.URL + "/v1/blueprints")
var bps []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&bps)
resp.Body.Close()
if len(bps) != 1 {
t.Fatalf("blueprints: %v", bps)
}
}
func TestCreateRunAcceptsLeaderModeOptions(t *testing.T) {
ts, eng := newTestServer(t)
defer ts.Close()
// leader 模式先过标准计划校验(规则骨架引用 researcher/writer/reviewer),
// 再进 leader 循环——四类角色都要注册。
for _, role := range []string{"leader", "researcher", "writer", "reviewer"} {
_, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": role, "backend": "script"})
}
// 直接驱动引擎拿可读错误(HTTP 信封吞 reason)。
if _, gerr := eng.Run(context.Background(), squad.Goal{Text: "写一份关于主题X的调研报告,输出markdown交付物"},
squad.RunOptions{Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 3}); gerr != nil {
t.Fatalf("engine leader run must return a result not an error, got: %v", gerr)
}
code, out := doJSON(t, "POST", ts.URL+"/v1/runs", map[string]any{
"goal": map[string]any{"text": "写一份关于主题X的调研报告,输出markdown交付物"},
"mode": "leader",
"leader_role": "leader",
"max_leader_turns": 3,
})
if code != http.StatusOK {
t.Fatalf("leader options must be accepted, got http %d: %v", code, out)
}
runID, _ := out["run_id"].(string)
if runID == "" {
t.Fatalf("run id missing: %v", out)
}
}