Why: N1 increment three (WP1/WP4) - agents need a canonical spec format with plugin restrictions, hosts need typed input/output/tool guardrails, delegation needs lifecycle events, and squad adapters need to pass the permission ask port through. Evidence: go vet clean; go test ./... 26 packages ok, including new agentspec (12 tests), guardrails (5), loop_guardrails (5), and subagent hook tests (3). Impact: new runtime/agentspec and runtime/guardrails packages; loop consumes UserPromptSubmit outcomes (deny skips the loop, modify appends the rewrite); subagent gains RegisterWithHooks emitting SubagentStart/Stop around delegation; GateOptions gains PermissionAsk forwarded into the kernel config. All additions are opt-in: nil ports preserve prior behavior exactly. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
132 lines
4.0 KiB
Go
132 lines
4.0 KiB
Go
package agentspec
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const goodSpec = `---
|
|
name: doc-reviewer
|
|
description: 评审体系文档章节
|
|
tools: read_file, grep, write_file
|
|
disallowedTools: run_shell
|
|
model: lightweight
|
|
skills: house-style, rdm-review
|
|
maxTurns: 8
|
|
memoryScope: project
|
|
effort: high
|
|
background: true
|
|
worktreeIsolation: false
|
|
outputSchema: review-result
|
|
---
|
|
你是章节评审代理。逐条核对来源标注。
|
|
`
|
|
|
|
func TestParseFullFields(t *testing.T) {
|
|
spec, err := Parse(goodSpec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spec.Name != "doc-reviewer" || spec.Description == "" {
|
|
t.Fatalf("identity = %+v", spec)
|
|
}
|
|
if len(spec.Tools) != 3 || spec.Tools[2] != "write_file" {
|
|
t.Fatalf("tools = %v", spec.Tools)
|
|
}
|
|
if len(spec.DisallowedTools) != 1 || spec.DisallowedTools[0] != "run_shell" {
|
|
t.Fatalf("disallowed = %v", spec.DisallowedTools)
|
|
}
|
|
if spec.Model != "lightweight" || spec.MaxTurns != 8 || spec.MemoryScope != "project" || spec.Effort != "high" {
|
|
t.Fatalf("scalars = %+v", spec)
|
|
}
|
|
if !spec.Background || spec.WorktreeIsolation {
|
|
t.Fatalf("bools = %+v", spec)
|
|
}
|
|
if len(spec.Skills) != 2 || spec.OutputSchema != "review-result" {
|
|
t.Fatalf("skills/schema = %+v", spec)
|
|
}
|
|
if spec.Prompt == "" {
|
|
t.Fatal("prompt body empty")
|
|
}
|
|
}
|
|
|
|
func TestParseDefaultsAndMinimal(t *testing.T) {
|
|
spec, err := Parse("---\nname: helper\n---\n做杂事。\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spec.MaxTurns != 0 || spec.Background || len(spec.Tools) != 0 || spec.PermissionMode != "" {
|
|
t.Fatalf("defaults = %+v", spec)
|
|
}
|
|
if spec.Prompt != "做杂事。" {
|
|
t.Fatalf("prompt = %q", spec.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsBadInputs(t *testing.T) {
|
|
cases := map[string]string{
|
|
"missing frontmatter": "name: x\n---\n正文\n",
|
|
"unclosed frontmatter": "---\nname: x\n正文\n",
|
|
"unknown key": "---\nname: x\nbogus: 1\n---\n正文\n",
|
|
"missing name": "---\ndescription: x\n---\n正文\n",
|
|
"bad name uppercase": "---\nname: Bad\n---\n正文\n",
|
|
"bad name underscore": "---\nname: has_under\n---\n正文\n",
|
|
"bad maxTurns": "---\nname: x\nmaxTurns: many\n---\n正文\n",
|
|
"bad bool": "---\nname: x\nbackground: yes\n---\n正文\n",
|
|
"malformed line": "---\nname: x\nno-colon-here\n---\n正文\n",
|
|
}
|
|
for label, content := range cases {
|
|
if _, err := Parse(content); err == nil {
|
|
t.Fatalf("%s: expected error", label)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParsePluginBansHostOnlyKeys(t *testing.T) {
|
|
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
|
content := "---\nname: plug-agent\n" + key + ": anything\n---\n正文\n"
|
|
if _, err := ParsePlugin(content); err == nil {
|
|
t.Fatalf("plugin key %s: expected rejection", key)
|
|
} else if got := err.Error(); !strings.Contains(got, "不得声明 "+key) {
|
|
t.Fatalf("plugin key %s: error %q 未定位到字段", key, got)
|
|
}
|
|
}
|
|
// 宿主来源不受此限:permissionMode 在 Parse 下可用。
|
|
if _, err := Parse("---\nname: host-agent\npermissionMode: plan\n---\n正文\n"); err != nil {
|
|
t.Fatalf("host parse with permissionMode: %v", err)
|
|
}
|
|
// 插件的合法字段照常解析。
|
|
spec, err := ParsePlugin("---\nname: plug-agent\ntools: read_file\n---\n正文\n")
|
|
if err != nil || len(spec.Tools) != 1 {
|
|
t.Fatalf("plugin good spec: %+v err=%v", spec, err)
|
|
}
|
|
}
|
|
|
|
func TestLoadDirAtomicAndDuplicate(t *testing.T) {
|
|
dir := t.TempDir()
|
|
write := func(name, content string) {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("a.md", "---\nname: alpha\n---\nA\n")
|
|
write("b.md", "---\nname: beta\n---\nB\n")
|
|
got, err := LoadDir(dir, nil)
|
|
if err != nil || len(got) != 2 {
|
|
t.Fatalf("load = %d err=%v", len(got), err)
|
|
}
|
|
|
|
write("c.md", "---\nname: alpha\n---\nC\n")
|
|
if _, err := LoadDir(dir, nil); err == nil {
|
|
t.Fatal("duplicate name accepted")
|
|
}
|
|
os.Remove(filepath.Join(dir, "c.md"))
|
|
|
|
write("bad.md", "---\nname: x\nbogus: 1\n---\n")
|
|
if _, err := LoadDir(dir, nil); err == nil {
|
|
t.Fatal("bad file did not fail the batch")
|
|
}
|
|
}
|