Why: Backend has to keep the same squad runs after the core stopped importing agent-native. The adapter module owns the concrete client. Evidence: GOWORK=off go test -count=1 ./internal/... passed. Impact: AgentNativeOptions now returns the adapter config. Default roles still use the agent-system backend. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
213 lines
7.1 KiB
Go
213 lines
7.1 KiB
Go
package squadadapter
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-native/runtime/subagent"
|
|
"agent-squad/squad"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func TestMapPlanAndStatus(t *testing.T) {
|
|
p := &squad.WorkflowPlan{Title: "示例编排", Steps: []squad.StepSpec{
|
|
{ID: "s1", Title: "梳理", Status: "completed"},
|
|
{ID: "s2", Title: "成文", Status: "running"},
|
|
{ID: "s3", Title: "终审", Status: "skipped"},
|
|
{ID: "s4", Title: "待办", Status: "pending"},
|
|
}}
|
|
snap := mapPlan(p)
|
|
if snap.Title != "示例编排" || len(snap.Steps) != 4 {
|
|
t.Fatalf("snapshot: %+v", snap)
|
|
}
|
|
want := []string{"done", "in_progress", "skipped", "pending"}
|
|
for i, w := range want {
|
|
if snap.Steps[i].Status != w {
|
|
t.Errorf("step %d: want %s got %s", i, w, snap.Steps[i].Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuiltinRolesValid(t *testing.T) {
|
|
reg := squad.NewRoleRegistry()
|
|
for _, r := range builtinRoles() {
|
|
if err := reg.Register(r); err != nil {
|
|
t.Errorf("role %s invalid: %v", r.Role, err)
|
|
}
|
|
if r.Backend != "agent-system" {
|
|
t.Errorf("role %s backend: %s", r.Role, r.Backend)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsHoldStatus(t *testing.T) {
|
|
for _, s := range []string{"waiting_human", "awaiting_confirmation", "awaiting_clarification", "quality_review"} {
|
|
if !isHoldStatus(s) {
|
|
t.Errorf("%s should be hold", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"completed", "executing", "failed"} {
|
|
if isHoldStatus(s) {
|
|
t.Errorf("%s should not be hold", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// subagentFakeRc 只实现 SpawnSubagent 的最小 Ctx 桩(其余方法本测试不触达)。
|
|
type subagentFakeRc struct {
|
|
gotName string
|
|
gotTask string
|
|
payload core.AgentResultPayload
|
|
err error
|
|
}
|
|
|
|
func (f *subagentFakeRc) Context() context.Context { return context.Background() }
|
|
func (f *subagentFakeRc) Run() core.Run { return core.Run{} }
|
|
func (f *subagentFakeRc) Settings() core.EffectiveSettings { return nil }
|
|
func (f *subagentFakeRc) Emit(typ core.StreamEventType, payload any) error { return nil }
|
|
func (f *subagentFakeRc) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
|
return core.HITLResponse{}, nil
|
|
}
|
|
func (f *subagentFakeRc) UpdatePlan(p core.PlanSnapshot) error { return nil }
|
|
func (f *subagentFakeRc) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
|
return core.Artifact{}, nil
|
|
}
|
|
func (f *subagentFakeRc) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
|
|
f.gotName, f.gotTask = name, task
|
|
return f.payload, f.err
|
|
}
|
|
|
|
func TestSubagentFactoryBridgesSpawnSubagent(t *testing.T) {
|
|
rc := &subagentFakeRc{payload: core.AgentResultPayload{Status: core.RunCompleted, Text: "子代理结论"}}
|
|
res, err := SubagentFactory(rc)(context.Background(), subagent.Request{
|
|
Goal: "查证数据", Inputs: "docs/v2", WriteScope: "research/",
|
|
NonGoals: "不做实现", DoneCriteria: "三来源对照",
|
|
Deliverables: []string{"notes.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.Success || res.Text != "子代理结论" {
|
|
t.Fatalf("bridge result wrong: %+v", res)
|
|
}
|
|
for _, want := range []string{"查证数据", "docs/v2", "research/", "不做实现", "三来源对照", "notes.md"} {
|
|
if !strings.Contains(rc.gotTask, want) {
|
|
t.Fatalf("task brief missing %q:\n%s", want, rc.gotTask)
|
|
}
|
|
}
|
|
|
|
// 未完成状态:Success=false 且 Err 带结束状态。
|
|
rc2 := &subagentFakeRc{payload: core.AgentResultPayload{Status: core.RunFailed, Summary: "预算耗尽"}}
|
|
res2, err := SubagentFactory(rc2)(context.Background(), subagent.Request{Goal: "g", DoneCriteria: "d"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res2.Success || !strings.Contains(res2.Err, "failed") || res2.Text != "预算耗尽" {
|
|
t.Fatalf("failure mapping wrong: %+v", res2)
|
|
}
|
|
|
|
// SpawnSubagent 报错透传。
|
|
rc3 := &subagentFakeRc{err: errors.New("spawn refused")}
|
|
if _, err := SubagentFactory(rc3)(context.Background(), subagent.Request{Goal: "g", DoneCriteria: "d"}); err == nil {
|
|
t.Fatal("spawn error must propagate")
|
|
}
|
|
}
|
|
|
|
type agentFakeRc struct {
|
|
subagentFakeRc
|
|
agentID string
|
|
}
|
|
|
|
func (f *agentFakeRc) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
|
f.agentID = agentID
|
|
f.gotName, f.gotTask = name, task
|
|
return f.payload, f.err
|
|
}
|
|
|
|
func TestStepRoleMapsToProfile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cases := []struct {
|
|
role string
|
|
want string
|
|
}{
|
|
{"planner", "agent.sub.plan"},
|
|
{"organizer", "agent.sub.plan"},
|
|
{"researcher", "agent.sub.research"},
|
|
{"analyst", "agent.sub.research"},
|
|
{"writer", "agent.sub.writer"},
|
|
{"reviewer", "agent.sub.verify"},
|
|
{"leader", "agent.sub.verify"},
|
|
{"specialist", "agent.sub.general"},
|
|
}
|
|
for _, tc := range cases {
|
|
rc := &agentFakeRc{subagentFakeRc: subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}}
|
|
b := &stepBackend{rc: rc, role: squad.RoleSpec{Role: tc.role, Name: "显示名"}}
|
|
res, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "做这件事", WorkDir: dir, Targets: []string{"output.md"}})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("%s: %+v %v", tc.role, res, err)
|
|
}
|
|
if rc.agentID != tc.want || rc.gotName != "显示名" {
|
|
t.Fatalf("%s mapped to %s name %s", tc.role, rc.agentID, rc.gotName)
|
|
}
|
|
}
|
|
plain := &subagentFakeRc{payload: core.AgentResultPayload{Text: "ok"}}
|
|
b := &stepBackend{rc: plain, role: squad.RoleSpec{Role: "writer", Name: "撰稿"}}
|
|
if _, err := b.Execute(context.Background(), squad.ExecRequest{Prompt: "写", WorkDir: dir}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if plain.gotName != "撰稿" {
|
|
t.Fatalf("fallback name = %s", plain.gotName)
|
|
}
|
|
}
|
|
|
|
func TestBuiltinRolesExplicitExecConfig(t *testing.T) {
|
|
roles := builtinRoles()
|
|
byKey := map[string]squad.RoleSpec{}
|
|
for _, r := range roles {
|
|
byKey[r.Role] = r
|
|
}
|
|
if len(roles) != 4 {
|
|
t.Fatalf("want 4 builtin roles, got %d", len(roles))
|
|
}
|
|
// 全员 BashPermission 显式声明。
|
|
for _, r := range roles {
|
|
switch r.BashPermission {
|
|
case squad.BashPermissionDeny, squad.BashPermissionAsk, squad.BashPermissionAllow:
|
|
default:
|
|
t.Fatalf("role %s bash_permission not explicit: %q", r.Role, r.BashPermission)
|
|
}
|
|
}
|
|
// 调研员:search + fetch;审校员:grep/glob;撰稿员:ask + 三套技能 + 生成工具。
|
|
for _, want := range []string{"web_search", "fetch_url"} {
|
|
if !containsStr(byKey["researcher"].Tools, want) {
|
|
t.Fatalf("researcher tools missing %s: %v", want, byKey["researcher"].Tools)
|
|
}
|
|
}
|
|
for _, want := range []string{"grep", "glob"} {
|
|
if !containsStr(byKey["reviewer"].Tools, want) {
|
|
t.Fatalf("reviewer tools missing %s: %v", want, byKey["reviewer"].Tools)
|
|
}
|
|
}
|
|
w := byKey["writer"]
|
|
if w.BashPermission != squad.BashPermissionAsk {
|
|
t.Fatalf("writer needs ask (run_python for docs), got %q", w.BashPermission)
|
|
}
|
|
if !containsStr(w.Tools, "run_python") || !containsStr(w.Tools, "load_skill") {
|
|
t.Fatalf("writer tools missing generation path: %v", w.Tools)
|
|
}
|
|
if len(w.Skills) != 3 || !containsStr(w.Skills, "docx-author") || !containsStr(w.Skills, "pptx-author") {
|
|
t.Fatalf("writer skills wrong: %v", w.Skills)
|
|
}
|
|
}
|
|
|
|
func containsStr(list []string, want string) bool {
|
|
for _, v := range list {
|
|
if v == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|