Files
Cyrene_v2/internal/runtime/orchestration/spawntool_test.go
T
AskaEth eb1828ee7a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/orchestration(29 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留
- 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
2026-10-04 13:30:04 +08:00

386 lines
12 KiB
Go

package orchestration
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// countingRunner 是一个调度器 runner,记录有多少次调用互相重叠,使测试能证明该批次
// 仍走调度器的并行闸门。
type countingRunner struct {
delay time.Duration
result *loop.Result
mu sync.Mutex
active int
maxActive int
total int
}
func (r *countingRunner) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
r.mu.Lock()
r.active++
r.total++
if r.active > r.maxActive {
r.maxActive = r.active
}
r.mu.Unlock()
if r.delay > 0 {
select {
case <-time.After(r.delay):
case <-ctx.Done():
}
}
r.mu.Lock()
r.active--
r.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if r.result != nil {
return r.result, nil
}
return &loop.Result{Text: "ok", Steps: 1}, nil
}
func (r *countingRunner) peak() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.maxActive
}
// spawnToolFor 在带给定选项与前台窗口的调度器上构建派发工具。
func spawnToolFor(t *testing.T, opts Options, wait time.Duration) (*Scheduler, *SpawnTool) {
t.Helper()
s := newScheduler(t, opts)
return s, NewSpawnTool(s, WithSpawnWait(wait))
}
// decodeSpawnResult 经 JSON 重新读取工具结果,使测试断言的是模型实际收到的形状,而不是
// 内存里的结构体。
func decodeSpawnResult(t *testing.T, raw any) SpawnResult {
t.Helper()
b, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal tool result: %v", err)
}
var out SpawnResult
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal tool result: %v", err)
}
return out
}
// statusOf 返回调度器为 id 报告的状态;未知时返回 ""。
func statusOf(s *Scheduler, id string) Status {
for _, a := range s.Agents() {
if a.ID == id {
return a.Status
}
}
return ""
}
func TestSpawnToolReturnsMergedResult(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{MaxParallel: 2}, time.Second)
s.runner = &mockRunner{result: &loop.Result{Text: "answer", Steps: 3, StopReason: "stop"}}
raw, err := sp.Execute(context.Background(), map[string]any{
"goal": "research X", "role": "cyrene", "mode": "engineer", "attach_info": "ctx",
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "done" {
t.Fatalf("Status = %q, want done", got.Status)
}
if len(got.Agents) != 1 {
t.Fatalf("Agents = %d, want 1", len(got.Agents))
}
a := got.Agents[0]
if a.Status != string(StatusDone) || a.Text != "answer" || a.Steps != 3 {
t.Fatalf("agent = %+v, want a done agent with text answer/3 steps", a)
}
if a.Goal != "research X" || a.Role != "cyrene" || a.Mode != "engineer" {
t.Fatalf("agent = %+v, want the goal/role/mode to round-trip", a)
}
if a.Summary == "" || !strings.Contains(got.Summary, "1 成功") {
t.Fatalf("summary = %q / %q, want a settled aggregate", got.Summary, a.Summary)
}
if statusOf(s, a.ID) != StatusDone {
t.Fatalf("scheduler status = %q, want done", statusOf(s, a.ID))
}
}
func TestSpawnToolParallelBatchHonorsParallelLimit(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1})
r := &countingRunner{delay: 20 * time.Millisecond, result: &loop.Result{Text: "ok", Steps: 1}}
s.runner = r
sp := NewSpawnTool(s, WithSpawnWait(5*time.Second))
raw, err := sp.Execute(context.Background(), map[string]any{
"tasks": []any{
map[string]any{"goal": "a"},
map[string]any{"goal": "b"},
map[string]any{"goal": "c"},
},
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "done" || len(got.Agents) != 3 {
t.Fatalf("status/agents = %q/%d, want done/3", got.Status, len(got.Agents))
}
if got.Agents[0].Goal != "a" || got.Agents[1].Goal != "b" || got.Agents[2].Goal != "c" {
t.Fatalf("agents = %+v, want dispatch order a/b/c", got.Agents)
}
for _, a := range got.Agents {
if a.Status != string(StatusDone) {
t.Fatalf("agent %s status = %q, want done", a.ID, a.Status)
}
}
// 该批次确实走过了调度器的信号量:MaxParallel=1 时三个子代理绝不可能同时运行。
if peak := r.peak(); peak != 1 {
t.Fatalf("max concurrent runs = %d, want 1 (the parallel limit)", peak)
}
if !strings.Contains(got.Summary, "3 成功") {
t.Fatalf("summary = %q, want 3 成功", got.Summary)
}
}
func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 2})
gate := make(chan struct{})
s.runner = &mockRunner{gate: gate, result: &loop.Result{Text: "late", Steps: 2}}
sp := NewSpawnTool(s, WithSpawnWait(40*time.Millisecond))
results := make(chan *SubagentResultEvent, 1)
unsub, err := s.Bus().Subscribe(context.Background(), SubagentResultSubject,
func(_ context.Context, e bus.Event) error {
if res, ok := e.Payload.(*SubagentResultEvent); ok {
select {
case results <- res:
default:
}
}
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// 派发回合的会话(loop 在 Run 顶部把它播进 ctx):回填载荷必须带上它,
// gateway 的中继才能把结果归属到父会话的实时流(design/05 §9)。
ctx := loop.WithFlowBranch(context.Background(), parentSession)
start := time.Now()
raw, err := sp.Execute(ctx, map[string]any{"goal": "slow"})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "running" {
t.Fatalf("Status = %q, want running (the batch outlived the foreground window)", got.Status)
}
if elapsed > time.Second {
t.Fatalf("Execute blocked for %v, want it to hand the turn back at ~40ms", elapsed)
}
if len(got.Agents) != 1 || got.Agents[0].Status != string(StatusRunning) || got.Agents[0].Goal != "slow" {
t.Fatalf("agents = %+v, want a running acknowledgement", got.Agents)
}
close(gate)
select {
case res := <-results:
if res.SessionID != parentSession {
t.Fatalf("result sessionId = %q, want the dispatching session %q", res.SessionID, parentSession)
}
if res.Status != "done" || len(res.Agents) != 1 {
t.Fatalf("result = %+v, want one settled agent", res)
}
if a := res.Agents[0]; a.Text != "late" || a.Steps != 2 || a.Goal != "slow" || a.Status != string(StatusDone) {
t.Fatalf("result agent = %+v, want the late/2-steps/done projection", a)
}
// 载荷形状是 api-client §3.10 登记的 WS 帧 data:键集锁定,避免
// 生产者悄悄改键而文档与实现分叉。
wire, merr := json.Marshal(res)
if merr != nil {
t.Fatalf("marshal result payload: %v", merr)
}
var keys map[string]json.RawMessage
if uerr := json.Unmarshal(wire, &keys); uerr != nil {
t.Fatalf("unmarshal result payload: %v", uerr)
}
for _, want := range []string{"sessionId", "status", "summary", "agents"} {
if _, ok := keys[want]; !ok {
t.Fatalf("result payload %s lacks key %q", wire, want)
}
}
case <-time.After(waitTimeout):
t.Fatal("a background batch never published its result payload")
}
}
func TestSpawnToolKeepsPerAgentTimeout(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: 40 * time.Millisecond})
s.runner = &mockRunner{gate: make(chan struct{})} // 永不关闭:阻塞到超时
sp := NewSpawnTool(s, WithSpawnWait(2*time.Second))
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "hang"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "done" {
t.Fatalf("Status = %q, want done (the batch settled by timing out)", got.Status)
}
a := got.Agents[0]
if a.Status != string(StatusAborted) {
t.Fatalf("agent status = %q, want aborted", a.Status)
}
if !strings.Contains(a.Summary, "超时") {
t.Fatalf("summary = %q, want a timeout notice", a.Summary)
}
}
func TestSpawnToolAbortStillCancels(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1})
s.runner = &mockRunner{gate: make(chan struct{})}
sp := NewSpawnTool(s, WithSpawnWait(20*time.Millisecond))
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "hang", "background": true})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "running" || len(got.Agents) != 1 {
t.Fatalf("status/agents = %q/%d, want running/1", got.Status, len(got.Agents))
}
id := got.Agents[0].ID
waitFor(t, func() bool { return statusOf(s, id) == StatusRunning }, waitTimeout)
if err := s.Abort(context.Background(), id); err != nil {
t.Fatalf("Abort: %v", err)
}
waitFor(t, func() bool { return statusOf(s, id) == StatusAborted }, waitTimeout)
}
func TestSpawnToolRejectsBadArgs(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{}, time.Millisecond)
s.runner = &mockRunner{result: &loop.Result{Text: "x"}}
tooMany := make([]any, 0, maxSpawnBatch+1)
for i := 0; i <= maxSpawnBatch; i++ {
tooMany = append(tooMany, map[string]any{"goal": fmt.Sprintf("g%d", i)})
}
cases := []struct {
name string
args any
want error
}{
{"args are not an object", "nope", nil},
{"no goal", map[string]any{}, ErrMissingGoal},
{"blank goal", map[string]any{"goal": " "}, ErrMissingGoal},
{"empty tasks", map[string]any{"tasks": []any{}}, ErrMissingGoal},
{"task without goal", map[string]any{"tasks": []any{map[string]any{"role": "x"}}}, ErrMissingGoal},
{"tasks is not an array", map[string]any{"tasks": "x"}, nil},
{"tasks entry is not an object", map[string]any{"tasks": []any{"x"}}, nil},
{"batch over the cap", map[string]any{"tasks": tooMany}, nil},
}
for _, tc := range cases {
_, err := sp.Execute(context.Background(), tc.args)
if err == nil {
t.Fatalf("%s: Execute succeeded, want an error", tc.name)
}
if tc.want != nil && !errors.Is(err, tc.want) {
t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want)
}
}
if got := len(s.Agents()); got != 0 {
t.Fatalf("rejected calls spawned %d sub-agents, want 0", got)
}
}
func TestSpawnToolWithoutScheduler(t *testing.T) {
t.Parallel()
sp := NewSpawnTool(nil)
_, err := sp.Execute(context.Background(), map[string]any{"goal": "x"})
if !errors.Is(err, ErrNoScheduler) {
t.Fatalf("err = %v, want ErrNoScheduler", err)
}
}
// TestSpawnToolThroughDispatcher 让该工具走真实的 dispatcher,使对外广告的 JSON
// Schema、参数校验与注册表的可达性标记都被演练到 —— 而不只是 Execute。
func TestSpawnToolThroughDispatcher(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{MaxParallel: 2}, time.Second)
s.runner = &mockRunner{result: &loop.Result{Text: "sub answer", Steps: 1}}
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if err := d.Register(sp); err != nil {
t.Fatalf("Register: %v", err)
}
schemas := d.ToolSchemas()
if len(schemas) != 1 || schemas[0].Name != SpawnToolName {
t.Fatalf("schemas = %+v, want the spawn tool advertised", schemas)
}
wrongArgs := map[string]any{"role": "cyrene"}
if _, err := d.Invoke(context.Background(), SpawnToolName, wrongArgs); err == nil {
t.Fatal("Invoke with neither goal nor tasks succeeded, want a schema/validation error")
} else if !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("err = %v, want ErrInvalidArgs", err)
}
raw, err := d.Invoke(context.Background(), SpawnToolName, map[string]any{"goal": "do it"})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if got := decodeSpawnResult(t, raw); got.Status != "done" || len(got.Agents) != 1 {
t.Fatalf("result = %+v, want one settled agent", got)
}
// 以默认标记(两个都 false)注册:仅主人可用,因此群聊或陌生人会话既看不到也
// 调不动它(design/11 §3)。
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(SpawnToolName) {
t.Fatal("owner was denied the spawn tool")
}
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(SpawnToolName) {
t.Fatal("group member may invoke the spawn tool; want owner-only")
}
if restrict.SetContext(reg, restrict.ActorStranger).Allow(SpawnToolName) {
t.Fatal("stranger may invoke the spawn tool; want owner-only")
}
}