Why: RoleSpec.Tools/Skills were dead fields, BashPermission only gated an HITL prompt with nothing to enforce, leader turns dropped process events and tool profile, and the HTTP API could not start leader mode. Evidence: go vet ./... clean; go test ./... 6 packages ok. New cases: step requests carry ToolAllowlist/Skills/SessionScope/ExecutionID and a step-wide shared deadline; buildAgentNativeOptions bash gating (deny/unset get no exec tools even when configured, ask/allow do, missing model fails closed); Derive inheriting tool_profile/tools/ skills/bash_permission with request overrides; role-level HitlPolicy making a bash=ask step fail under auto instead of holding; leader requests fully assembled with detail events bridged to the sink; HTTP createRun accepting mode/leader_role/max_leader_turns (engine-driven repro of the plan-validation precondition included). Impact: RegisterAgentNative keeps its signature and delegates to a new RegisterAgentNativeFull(AgentNativeConfig); nested agent-native gitlink pinned to f9ab2ef (full-agent batch); llm planner derive schema gains tool_profile/tools/skills/bash_permission. Refs: Dev task feature/full-agent-r1 batch B9; agent-native PR #3.
333 lines
9.8 KiB
Go
333 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-native/exec"
|
|
"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 exec.Request) (*exec.Result, 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 &exec.Result{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)
|
|
}
|
|
}
|