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>
154 lines
5.0 KiB
Go
154 lines
5.0 KiB
Go
package squad
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// captureBackend 记录收到的请求(v2 字段贯通断言用)。
|
|
type captureBackend struct {
|
|
mu sync.Mutex
|
|
reqs []ExecRequest
|
|
script map[string]string // 子目录名 → 单一目标内容
|
|
}
|
|
|
|
func (b *captureBackend) Name() string { return "capture" }
|
|
|
|
func (b *captureBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
|
b.mu.Lock()
|
|
b.reqs = append(b.reqs, req)
|
|
b.mu.Unlock()
|
|
key := filepath.Base(filepath.Clean(req.WorkDir))
|
|
content := b.script[key]
|
|
if content == "" {
|
|
content = "# 默认交付物\n\n脚本后端生成的合格内容,长度满足最小质检要求。\n"
|
|
}
|
|
patches := map[string][]byte{}
|
|
for _, t := range req.Targets {
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
_ = os.WriteFile(out, []byte(content), 0o644)
|
|
patches[t] = []byte(content)
|
|
}
|
|
return &ExecResult{Success: true, Patches: patches}, nil
|
|
}
|
|
|
|
func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
role := RoleSpec{
|
|
Role: "researcher", Name: "调研", Backend: "capture",
|
|
Tools: []string{"read_file", "grep"}, Skills: []string{"docx-author"},
|
|
}
|
|
if err := reg.Register(role); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capture := &captureBackend{}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("capture", capture)
|
|
engine, err := NewEngine(Options{
|
|
DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"output.md"}}),
|
|
Backends: backends, Sink: NopSink{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := engine.Run(context.Background(), Goal{Text: "生成测试报告"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunCompleted {
|
|
t.Fatalf("want completed, got %s (err=%s)", res.Status, res.Error)
|
|
}
|
|
if len(capture.reqs) == 0 {
|
|
t.Fatal("capture backend never invoked")
|
|
}
|
|
req := capture.reqs[0]
|
|
if len(req.ToolAllowlist) != 2 || req.ToolAllowlist[0] != "read_file" {
|
|
t.Fatalf("role.Tools must flow into ToolAllowlist, got %v", req.ToolAllowlist)
|
|
}
|
|
if len(req.Skills) != 1 || req.Skills[0] != "docx-author" {
|
|
t.Fatalf("role.Skills must flow into request, got %v", req.Skills)
|
|
}
|
|
if !strings.Contains(req.SessionScope, "s1") || !strings.Contains(req.ExecutionID, "s1") {
|
|
t.Fatalf("identifiers missing: scope=%q exec=%q", req.SessionScope, req.ExecutionID)
|
|
}
|
|
if req.Budget == nil || time.Until(req.Budget.Deadline) <= 0 {
|
|
t.Fatal("step budget deadline must be set and in the future")
|
|
}
|
|
}
|
|
|
|
func TestDeriveInheritsExecutionConfig(t *testing.T) {
|
|
reg := NewRoleRegistry()
|
|
base := RoleSpec{
|
|
Role: "worker", Backend: "agent-native", Model: "m", ToolProfile: "workspace",
|
|
Tools: []string{"read_file"}, Skills: []string{"s1"}, BashPermission: "ask",
|
|
}
|
|
if err := reg.Register(base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
derived, err := reg.Derive(DeriveRequest{
|
|
RoleKey: "py-worker", BaseRole: "worker",
|
|
Tools: []string{"read_file", "run_python"},
|
|
Skills: []string{"s1", "xlsx-author"},
|
|
ToolProfile: "workspace",
|
|
BashPermission: "allow",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if derived.ToolProfile != "workspace" || derived.BashPermission != "allow" {
|
|
t.Fatalf("derive must honor explicit exec config: %+v", derived)
|
|
}
|
|
if len(derived.Tools) != 2 || len(derived.Skills) != 2 {
|
|
t.Fatalf("derive must take request slices over base: tools=%v skills=%v", derived.Tools, derived.Skills)
|
|
}
|
|
// 未显式给定时继承基线。
|
|
inherits, err := reg.Derive(DeriveRequest{RoleKey: "plain", BaseRole: "worker"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if inherits.BashPermission != "ask" || len(inherits.Tools) != 1 {
|
|
t.Fatalf("derive must inherit base exec config: %+v", inherits)
|
|
}
|
|
}
|
|
|
|
func TestRoleHitlPolicyOverridesGlobal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := NewRoleRegistry()
|
|
// bash=ask + 角色级 auto:不再挂起等审批,而是直接失败(auto 无值守语义)。
|
|
if err := reg.Register(RoleSpec{
|
|
Role: "runner", Name: "执行", Backend: "capture", BashPermission: BashPermissionAsk,
|
|
HitlPolicy: &HitlPolicy{Mode: HitlAuto},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capture := &captureBackend{}
|
|
backends := NewBackendResolver()
|
|
backends.RegisterInstance("capture", capture)
|
|
engine, err := NewEngine(Options{
|
|
DataDir: dir, Registry: reg,
|
|
Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "runner", Title: "跑", Targets: []string{"output.md"}}),
|
|
Backends: backends, Sink: NopSink{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := engine.Run(context.Background(), Goal{Text: "生成测试报告"}, RunOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Status != RunFailed || !strings.Contains(res.Error, "permission denied") {
|
|
t.Fatalf("role auto policy must fail the bash=ask step instead of holding, got %s err=%s", res.Status, res.Error)
|
|
}
|
|
if len(capture.reqs) != 0 {
|
|
t.Fatal("unapproved bash step must never execute")
|
|
}
|
|
}
|