Files
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

235 lines
8.3 KiB
Go

package orchestration
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"testing"
"time"
"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"
)
// 本文件锁定 design/22 §8-2 的契约那一半(行为那一半在 spawnpersona_test.go):模型
// 看到的工具 schema 与结果形状、未知角色的拒绝,以及子代理运行被盖上的会话 env。这些
// 测试的重点是:不指名 persona 的派发与改动前的行为逐字节一致,而指名一个不可用角色的
// 派发会明确报错,而不是降级为默认角色。
// TestSpawnPersonaRejectsUnknownRole 是「未知角色 → 明确报错,不静默回落」那一半:本
// 进程跑不了的 id 会在任何东西启动之前就被拒绝,单次派发与批次都是如此(全有或全无,
// 因此不会留下半跑的批次);空 id 也绝不会被当作角色。
func TestSpawnPersonaRejectsUnknownRole(t *testing.T) {
t.Parallel()
cases := []struct {
name string
exists func(string) bool
args map[string]any
wantErr bool
}{
{
name: "unknown persona",
exists: func(id string) bool { return id == targetRole },
args: map[string]any{"goal": "x", "persona": "nobody"},
wantErr: true,
},
{
name: "no resolver wired",
exists: nil,
args: map[string]any{"goal": "x", "persona": targetRole},
wantErr: true,
},
{
name: "batch with one unknown persona",
exists: func(id string) bool { return id == targetRole },
args: map[string]any{"tasks": []any{map[string]any{"goal": "a", "persona": targetRole}, map[string]any{"goal": "b", "persona": "nobody"}}},
wantErr: true,
},
{
name: "known persona",
exists: func(id string) bool { return id == targetRole },
args: map[string]any{"goal": "x", "persona": targetRole},
},
{
name: "blank persona is not a role",
exists: nil,
args: map[string]any{"goal": "x", "persona": " "},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rig := newPersonaRig(t, tc.exists)
_, err := rig.tool.Execute(parentTurnCtx(), tc.args)
if tc.wantErr {
if !errors.Is(err, ErrUnknownPersona) {
t.Fatalf("err = %v, want ErrUnknownPersona", err)
}
if got := len(rig.sched.Agents()); got != 0 {
t.Fatalf("a rejected dispatch started %d sub-agents, want 0", got)
}
return
}
if err != nil {
t.Fatalf("Execute: %v", err)
}
})
}
}
// TestSchedulerSpawnCarriesPersonaEnv 为绕过该工具的调用方钉住同一契约的库那一半:
// Spawn 把目标角色盖到运行的 env 上(使循环解析该角色的提示词与记忆作用域),空 persona
// 让继承来的 env 原样不动,Spawn 自身也拒绝未知角色。
func TestSchedulerSpawnCarriesPersonaEnv(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{PersonaExists: func(id string) bool { return id == targetRole }})
envs := make(chan loop.SessionEnv, 4)
s.runner = envRunner{envs: envs}
sub, err := s.Spawn(parentTurnCtx(), AgentSpec{Goal: "g", PersonaID: targetRole})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// 记录下来的绑定也点名目标角色,因此进度事件与 ws subagent_event 帧报告的是子代理
// 以之作答的角色。
if sub.RoleID != targetRole || sub.PersonaID != targetRole {
t.Fatalf("sub-agent binding = %q/%q, want the target role on both", sub.RoleID, sub.PersonaID)
}
if _, err := s.Wait(context.Background(), []string{sub.ID}); err != nil {
t.Fatalf("Wait: %v", err)
}
if env := <-envs; env.PersonaID != targetRole || env.ActorEnv != restrict.ActorOwner || !env.MemWritable {
t.Fatalf("run env = %+v, want the target persona on the unchanged owner lane", env)
}
legacy, err := s.Spawn(parentTurnCtx(), AgentSpec{Goal: "g"})
if err != nil {
t.Fatalf("Spawn (no persona): %v", err)
}
if _, err := s.Wait(context.Background(), []string{legacy.ID}); err != nil {
t.Fatalf("Wait (no persona): %v", err)
}
if env := <-envs; env.PersonaID != parentRole {
t.Fatalf("persona-less run env = %+v, want the inherited role %q untouched", env, parentRole)
}
if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "g", PersonaID: "nobody"}); !errors.Is(err, ErrUnknownPersona) {
t.Fatalf("Spawn with an unknown persona: err = %v, want ErrUnknownPersona", err)
}
}
// envRunner 记录每次子代理运行起步时的会话 env。一次运行使用哪个记忆作用域正是由这个值
// 决定的,因此对直接走 Scheduler.Spawn 的调用方而言,它就是被测边界。
type envRunner struct {
envs chan loop.SessionEnv
}
func (r envRunner) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
r.envs <- loop.SessionEnvFrom(ctx)
return &loop.Result{Text: "ok", Steps: 1}, nil
}
// TestSpawnToolResultShapeIsUnchangedWithoutPersona 钉住模型可见结果的严格向后兼容:
// 不指名 persona 的派发仍返回与引入本功能之前完全相同的 JSON 键 —— 任何地方都没有新增
// "persona" 字段 —— 因此省略 persona 的契约一点没动。
func TestSpawnToolResultShapeIsUnchangedWithoutPersona(t *testing.T) {
t.Parallel()
s, sp := spawnToolFor(t, Options{}, time.Second)
s.runner = &mockRunner{result: &loop.Result{Text: "done", Steps: 2, StopReason: "stop"}}
raw, err := sp.Execute(context.Background(), map[string]any{
"goal": "g", "role": "cyrene", "mode": "engineer",
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
b, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var top map[string]any
if err := json.Unmarshal(b, &top); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := sortedKeys(top); strings.Join(got, ",") != "agents,status,summary" {
t.Fatalf("result keys = %v, want the unchanged agents/status/summary", got)
}
agents, _ := top["agents"].([]any)
if len(agents) != 1 {
t.Fatalf("agents = %v, want one entry", top["agents"])
}
agent, _ := agents[0].(map[string]any)
want := "goal,id,mode,role,status,steps,summary,text"
if got := sortedKeys(agent); strings.Join(got, ",") != want {
t.Fatalf("agent keys = %v, want %s (no new client-visible field)", got, want)
}
}
// sortedKeys 按字典序返回 m 的键,供测试做稳定比较。
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// TestSpawnSchemaKeepsPersonaOptional 钉住工具契约:persona 在单 goal 与 tasks 两级都是
// 可选字符串,没有新增任何必填项,既有参数含义不变 —— 因此从不发 persona 的模型(或旧
// 客户端)看到的校验行为逐字节相同。
func TestSpawnSchemaKeepsPersonaOptional(t *testing.T) {
t.Parallel()
props, err := NewSpawnTool(nil).Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
// 必填集合未变:goal 或 tasks,别无其他。
if got := requiredAnyOf(t, props); strings.Join(got, ",") != "goal,tasks" {
t.Fatalf("anyOf required = %v, want goal/tasks only", got)
}
top := stringProp(t, props, "persona")
if top == nil {
t.Fatal("the top-level persona property is missing")
}
if top["type"] != "string" {
t.Fatalf("persona type = %v, want string", top["type"])
}
items, _ := props["properties"].(map[string]any)["tasks"].(map[string]any)["items"].(map[string]any)
taskProps, _ := items["properties"].(map[string]any)
if _, ok := taskProps["persona"]; !ok {
t.Fatal("a tasks entry cannot name a persona")
}
if req, _ := items["required"].([]any); len(req) != 1 || req[0] != "goal" {
t.Fatalf("tasks required = %v, want goal only", req)
}
}
// requiredAnyOf 摊平工具 schema 的各个 anyOf required 列表。
func requiredAnyOf(t *testing.T, props map[string]any) []string {
t.Helper()
anyOf, _ := props["anyOf"].([]any)
out := make([]string, 0, len(anyOf))
for _, entry := range anyOf {
m, _ := entry.(map[string]any)
req, _ := m["required"].([]any)
for _, r := range req {
s, _ := r.(string)
out = append(out, s)
}
}
return out
}
// stringProp 返回工具 schema 属性块里指名的顶层属性。
func stringProp(t *testing.T, props map[string]any, name string) map[string]any {
t.Helper()
properties, _ := props["properties"].(map[string]any)
prop, _ := properties[name].(map[string]any)
return prop
}