feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)

llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+531
View File
@@ -0,0 +1,531 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.gopher.ink/mirrors2/AutoZero/diag"
"time"
)
// 本测试用 Go 的 helper-process 模式:重新执行测试二进制作为一个"真的"子进程
// MCP server,端到端验证年代探测 → tools/list → tools/call。
// 用 AUTOGO_MCP_ERA 选择假服务端说哪个年代:modern(2026-07-28)或 legacy(握手式)。
//
// 假服务端会在 stdout **先打一行非 JSON 的 banner**:这是很多真实 server 的行为,
// 而客户端必须容忍它(历史上用 json.Decoder 解流时,这一行会让读循环永久停摆)。
// syncBuf 是并发安全的 writer(子进程 stderr 由 os/exec 的拷贝 goroutine 写入,
// 测试同时读取,裸 bytes.Buffer 会被 -race 抓)。
type syncBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuf) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuf) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
// helperConfig 组装"用测试二进制当 MCP server"的启动配置,并把诊断输出接到
// 一个并发安全的 buffer(MCP 的诊断现在走 diag,默认是 os.Stderr)。
func helperConfig(t *testing.T, testName, era string) (Config, *syncBuf) {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
logs := &syncBuf{}
diag.SetWriter(logs)
t.Cleanup(func() { diag.SetWriter(nil) })
cfg := Config{
Command: exe,
Args: []string{"-test.run=" + testName, "-test.v=false"},
Env: map[string]string{"AUTOGO_MCP_HELPER": "1", "AUTOGO_MCP_ERA": era},
}
return cfg, logs
}
// runFakeMCPServer 在 stdin/stdout 上实现最小 MCP server(按 era 分流)。
//
// modern:实现 server/discover,**要求每个请求都带 _meta**(缺了就往 stderr 写
// META-MISSING 并退出,父进程据此断言);结果都带 resultType。
// legacy:对 server/discover 回 -32601,靠 initialize 握手。
func runFakeMCPServer() {
era := os.Getenv("AUTOGO_MCP_ERA")
// banner:真实 server 常把启动日志打到 stdout,客户端必须跳过它。
fmt.Fprintln(os.Stdout, "fake-mcp-server starting (this line is not JSON)")
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
enc := json.NewEncoder(os.Stdout)
fail := func(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
sawMeta := true
for {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Meta map[string]any `json:"_meta"`
RequestState string `json:"requestState"`
} `json:"params"`
}
if err := dec.Decode(&req); err != nil {
return
}
if len(req.ID) == 0 {
continue // 通知(legacy 的 notifications/initialized)
}
if era == "modern" {
if req.Params.Meta == nil {
sawMeta = false
fail("META-MISSING on method %s", req.Method)
}
for _, k := range []string{metaProtocolVersion, metaClientCapabilities, metaClientInfo} {
if _, ok := req.Params.Meta[k]; !ok {
sawMeta = false
fail("META-KEY-MISSING %s on method %s", k, req.Method)
}
}
}
_ = sawMeta
id := string(req.ID)
resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)}
switch req.Method {
case "server/discover":
switch {
case era == "modern":
resp["result"] = map[string]any{
"resultType": "complete",
"supportedVersions": []string{"2026-07-28"},
"capabilities": map[string]any{"tools": map[string]any{}},
"_meta": map[string]any{
metaServerInfo: map[string]any{"name": "fake-modern", "version": "2.0.0"},
},
"instructions": "fake server for tests",
}
case era == "oldversion":
// 现代服务端,但只支持旧修订版:客户端应回退到握手。
resp["error"] = map[string]any{
"code": errUnsupportedProtocolVersion, "message": "Unsupported protocol version",
"data": map[string]any{"supported": []string{"2025-11-25"}, "requested": protocolVersion},
}
default:
resp["error"] = map[string]any{"code": -32601, "message": "method not found: " + req.Method}
}
case "initialize":
resp["result"] = map[string]any{
"protocolVersion": legacyVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "fake-legacy", "version": "1.0.0"},
}
case "tools/list":
r := map[string]any{
"tools": []map[string]any{{
"name": "echo",
"description": "Echo the given text",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string"},
"fail": map[string]any{"type": "boolean"},
"mrt": map[string]any{"type": "boolean"},
"ask": map[string]any{"type": "boolean"},
},
},
}},
}
if era == "modern" {
r["resultType"] = "complete"
}
resp["result"] = r
case "tools/call":
var args struct {
Text string `json:"text"`
Fail bool `json:"fail"`
MRT bool `json:"mrt"`
Ask bool `json:"ask"`
}
_ = json.Unmarshal(req.Params.Arguments, &args)
// MRTR:第一次回 input_required,重试才给结果。
if args.MRT && req.Params.RequestState == "" {
resp["result"] = map[string]any{
"resultType": "input_required",
"requestState": "st-" + id, // 把首次 id 编进去,客户端必须原样回显
}
_ = enc.Encode(resp)
continue
}
if args.MRT {
resp["result"] = map[string]any{
"resultType": "complete",
"content": []map[string]any{{"type": "text",
"text": "retried state=" + req.Params.RequestState + " id=" + id}},
"isError": false,
}
_ = enc.Encode(resp)
continue
}
// 要求客户端未声明能力的输入(合规服务端不该这么干)。
if args.Ask {
resp["result"] = map[string]any{
"resultType": "input_required",
"inputRequests": map[string]any{
"github_login": map[string]any{
"method": "elicitation/create",
"params": map[string]any{"message": "username?"},
},
},
}
_ = enc.Encode(resp)
continue
}
content := []map[string]any{{"type": "text", "text": "echo: " + args.Text}}
if args.Fail {
content[0]["text"] = "simulated failure: " + args.Text
resp["result"] = map[string]any{"content": content, "isError": true}
} else {
resp["result"] = map[string]any{"content": content, "isError": false}
}
default:
resp["error"] = map[string]any{"code": -32601, "message": "method not found: " + req.Method}
}
if err := enc.Encode(resp); err != nil {
return
}
}
}
// TestMCPModernProtocol:2026-07-28 现代协议端到端。
// 核心断言是"每个请求都带三个 _meta 键"——假服务端缺了就写 META-MISSING 并自杀。
func TestMCPModernProtocol(t *testing.T) {
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
runFakeMCPServer()
return
}
cfg, logs := helperConfig(t, "TestMCPModernProtocol", "modern")
cli, err := Start("fake", cfg)
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
if cli.Era() != eraModern {
t.Errorf("era = %q, want modern", cli.Era())
}
if cli.Version() != protocolVersion {
t.Errorf("version = %q, want %q", cli.Version(), protocolVersion)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
toolsList, err := cli.ListTools(ctx)
if err != nil {
t.Fatalf("list tools: %v", err)
}
if len(toolsList) != 1 || toolsList[0].Name != "echo" {
t.Fatalf("unexpected tools: %+v", toolsList)
}
got, err := cli.CallTool(ctx, "echo", `{"text":"hi autogo"}`)
if err != nil {
t.Fatalf("call echo: %v", err)
}
if got != "echo: hi autogo" {
t.Fatalf("echo result: %q", got)
}
if _, err := cli.CallTool(ctx, "echo", `{"text":"boom","fail":true}`); err == nil {
t.Fatal("expected error from isError result")
}
if s := logs.String(); strings.Contains(s, "META-MISSING") || strings.Contains(s, "META-KEY-MISSING") {
t.Fatalf("请求缺少必需的 _meta:%s", s)
}
// AsTool 包装 + fail-closed 标注
tl := cli.AsTool("fake", "echo", "Echo text", toolsList[0].InputSchema)
if tl.Name != "mcp__fake__echo" || !tl.SideEffect || tl.ConcurrencySafe {
t.Fatalf("AsTool wrapping wrong: %+v", tl)
}
res, err := tl.Run(context.Background(), `{"text":"wrapped"}`)
if err != nil || res.Text != "echo: wrapped" {
t.Fatalf("wrapped run: %+v %v", res, err)
}
}
// TestMCPLegacyFallback:服务端不认识 server/discover(回 -32601)时必须回退到
// initialize 握手,并且回退之后的请求**不能再带 _meta**。
func TestMCPLegacyFallback(t *testing.T) {
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
runFakeMCPServer()
return
}
cfg, _ := helperConfig(t, "TestMCPLegacyFallback", "legacy")
cli, err := Start("fake", cfg)
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
if cli.Era() != eraLegacy {
t.Fatalf("era = %q, want legacy(discover 回 -32601 应回退握手)", cli.Era())
}
if cli.Version() != legacyVersion {
t.Errorf("version = %q, want %q", cli.Version(), legacyVersion)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := cli.CallTool(ctx, "echo", `{"text":"legacy hi"}`)
if err != nil {
t.Fatalf("call echo: %v", err)
}
if got != "echo: legacy hi" {
t.Fatalf("echo result: %q", got)
}
}
// TestMCPUnsupportedVersionFallsBack:现代服务端回 -32022(只支持 2025-11-25)时,
// 客户端应从 data.supported 里判断出"讲不了现代版本",回退握手而不是硬发 _meta。
func TestMCPUnsupportedVersionFallsBack(t *testing.T) {
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
runFakeMCPServer()
return
}
cfg, _ := helperConfig(t, "TestMCPUnsupportedVersionFallsBack", "oldversion")
cli, err := Start("fake", cfg)
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
if cli.Era() != eraLegacy {
t.Fatalf("era = %q, want legacy", cli.Era())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := cli.ListTools(ctx); err != nil {
t.Fatalf("list tools: %v", err)
}
}
// TestMCPMRTRRequestState:服务端回 input_required + requestState 时,客户端要
// **换新 id、原样回显 requestState** 重试。
func TestMCPMRTRRequestState(t *testing.T) {
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
runFakeMCPServer()
return
}
cfg, _ := helperConfig(t, "TestMCPMRTRRequestState", "modern")
cli, err := Start("fake", cfg)
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := cli.CallTool(ctx, "echo", `{"text":"x","mrt":true}`)
if err != nil {
t.Fatalf("call with MRTR: %v", err)
}
// 假服务端把首次 id 编进 requestState(st-<id>),重试时回显;最终内容形如
// "retried state=st-2 id=3"。断言:state 被原样带回,且重试用的是**新 id**。
var stID, retryID string
if _, err := fmt.Sscanf(got, "retried state=st-%s id=%s", &stID, &retryID); err != nil {
t.Fatalf("无法解析重试结果 %q: %v", got, err)
}
if stID == "" || retryID == "" {
t.Fatalf("重试结果格式异常: %q", got)
}
// stID 形如 "2",retryID 形如 "3"(首尾可能有空白/括号)
stID = strings.TrimSpace(strings.Trim(stID, "()"))
retryID = strings.TrimSpace(strings.Trim(retryID, "()"))
if stID == retryID {
t.Errorf("重试必须用新的 JSON-RPC id,实际 state 里的 id=%s、重试 id=%s", stID, retryID)
}
}
// TestMCPMRTRInputRequests:服务端要求我们未声明能力的输入(elicitation)时,
// 必须给出可操作的错误,而不是空转或静默失败。
func TestMCPMRTRInputRequests(t *testing.T) {
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
runFakeMCPServer()
return
}
cfg, _ := helperConfig(t, "TestMCPMRTRInputRequests", "modern")
cli, err := Start("fake", cfg)
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = cli.CallTool(ctx, "echo", `{"text":"x","ask":true}`)
if err == nil {
t.Fatal("服务端要求未声明能力的输入时应报错")
}
if !strings.Contains(err.Error(), "github_login") {
t.Errorf("错误里应列出服务端要什么,实际: %v", err)
}
}
// TestPickVersion 与 idKey 的边界。
func TestPickVersionAndIDKey(t *testing.T) {
if got := pickVersion([]string{"2025-03-26", protocolVersion}); got != protocolVersion {
t.Errorf("pickVersion 应优先最新: %q", got)
}
if got := pickVersion([]string{"2025-06-18"}); got != "2025-06-18" {
t.Errorf("pickVersion = %q", got)
}
if got := pickVersion([]string{"2099-01-01"}); got != "" {
t.Errorf("没有共同版本时应返回空,实际 %q", got)
}
// 有些实现会把数字 id 回成字符串,归一化后必须能对上。
if idKey(json.RawMessage(`1`)) != idKey(json.RawMessage(`"1"`)) {
t.Error("数字 id 与字符串 id 应归一化成同一个键")
}
}
// TestLoadConfigs:合并与同名覆盖。
func TestLoadConfigs(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a.json")
b := filepath.Join(dir, "b.json")
os.WriteFile(a, []byte(`{"mcpServers":{"s1":{"command":"a-cmd"},"s2":{"command":"b-cmd"}}}`), 0o644)
os.WriteFile(b, []byte(`{"mcpServers":{"s1":{"command":"override","args":["-x"]}},"bad":{}}`), 0o644)
got := LoadConfigs(a, filepath.Join(dir, "missing.json"), b)
if len(got) != 2 {
t.Fatalf("want 2 servers, got %d: %+v", len(got), got)
}
if got["s1"].Command != "override" || len(got["s1"].Args) != 1 {
t.Fatalf("s1 should be overridden by later file: %+v", got["s1"])
}
if got["s2"].Command != "b-cmd" {
t.Fatalf("s2: %+v", got["s2"])
}
if _, ok := got["bad"]; ok {
t.Fatal("server without command should be dropped")
}
}
// TestLoadConfigsHTTPAndWarnings:url 型条目要**保留**(以前会被静默丢弃),
// 无法判断传输类型的条目要丢掉但**留下日志**——列表里少一个 server 却没有线索,
// 是最难查的那种问题。
func TestLoadConfigsHTTPAndWarnings(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "mcp.json")
body := `{"mcpServers":{
"stdioOne":{"command":"npx","args":["-y","x"]},
"httpOne":{"type":"http","url":"https://example.com/mcp","headers":{"Authorization":"Bearer t"}},
"deprecated":{"type":"sse","url":"https://example.com/sse"},
"broken":{"env":{"A":"1"}}
}}`
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
var logged bytes.Buffer
prev := log.Writer()
log.SetOutput(&logged)
defer log.SetOutput(prev)
got := LoadConfigs(p)
if len(got) != 2 {
t.Fatalf("应保留 stdio 与 http 两条,实际 %d: %+v", len(got), got)
}
if got["httpOne"].URL == "" || got["httpOne"].Kind() != "http" {
t.Errorf("url 型条目没被保留/识别: %+v", got["httpOne"])
}
if got["stdioOne"].Kind() != "stdio" {
t.Errorf("stdio 型识别错误: %+v", got["stdioOne"])
}
if _, ok := got["deprecated"]; ok {
t.Error("已废弃的 sse 传输不该被当成可用配置")
}
out := logged.String()
if !strings.Contains(out, "deprecated") || !strings.Contains(out, "sse") {
t.Errorf("废弃的 sse 条目应有明确日志,实际: %q", out)
}
if !strings.Contains(out, "broken") {
t.Errorf("无法判断传输类型的条目应有日志,实际: %q", out)
}
}
// TestRPCError:server 返回 JSON-RPC error 时转为 Go error(且保留错误码)。
// 顺带覆盖"探测遇到非现代错误 ⇒ 回退 legacy"这条路径。
func TestRPCError(t *testing.T) {
if os.Getenv("AUTOGO_MCP_ERR_HELPER") == "1" {
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
enc := json.NewEncoder(os.Stdout)
for {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := dec.Decode(&req); err != nil {
return
}
if len(req.ID) == 0 {
continue
}
var resp map[string]any
if req.Method == "initialize" {
resp = map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID),
"result": map[string]any{"protocolVersion": legacyVersion}}
} else {
resp = map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID),
"error": map[string]any{"code": -32601, "message": "no such method"}}
}
_ = enc.Encode(resp)
}
}
exe, _ := os.Executable()
cli, err := Start("errfake", Config{
Command: exe,
Args: []string{"-test.run=TestRPCError"},
Env: map[string]string{"AUTOGO_MCP_ERR_HELPER": "1"},
})
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = cli.ListTools(ctx)
if err == nil || !strings.Contains(err.Error(), "no such method") {
t.Fatalf("expected rpc error, got %v", err)
}
// 错误里要保留错误码(年代探测靠它判 -32022)。
var ce *rpcCallError
if !errors.As(err, &ce) || ce.code != -32601 {
t.Fatalf("错误码没保留下来: %v", err)
}
}