Files
AutoZero/diag/no_terminal_writes_test.go
T
Sakurasan d5dac5a6fc 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。
2026-10-26 00:54:46 +08:00

89 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package diag
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// TestNoDirectTerminalWrites:TUI 跑在 alt-screen 下,**任何**直写 os.Stdout/os.Stderr
// 都会打穿界面。工具可达的包(工具实现、协议客户端、调度器……)都拿不到 agent 的
// Opt.Stderr,所以很容易顺手 fmt.Fprintf(os.Stderr, ...) 或 log.Printf——这条约定以前
// 只写在注释里,靠人记得,而实际就漏过好几处:
//
// - webui 的 11 处 log.Printf(默认 logger 直写 os.Stderr;而 WebUI 就在 TUI 里跑,
// SetLogOutput 只接管了 gin 的 writer);
// - MCP 子进程的 stderr 直连终端(而 MCP 的惯例恰恰是往 stderr 打日志);
// - computer 工具的三处区域截图警告与 Windows 子进程 stderr。
//
// 所以做成静态检查:工具可达包里出现 os.Stdout / os.Stderr / fmt.Print(log|f)(
// 就失败。诊断请走 diag;子进程的 stderr 用 diag.Indirect()。
//
// 用 go/ast 而不是 grep:注释里也会出现 "os.Stderr" 字样(本来就是在讲这件事),
// 文本匹配会误伤。
func TestNoDirectTerminalWrites(t *testing.T) {
// 工具调用可达的包(能跑在 TUI 里的路径)。列出当前与规划中的框架包;
// 尚未迁入的包 ReadDir 失败自动跳过。
pkgs := []string{
"tools", "agent", "cdp", "mcp", "session", "llm",
"execx", "jsonl", "textx", "scratch",
}
// 允许清单:file → 理由。目前为空——确有必要时再加,并写清为什么。
allowed := map[string]string{}
fset := token.NewFileSet()
var offenders []string
for _, pkg := range pkgs {
dir := filepath.Join("..", pkg)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
path := filepath.Join(dir, name)
if _, ok := allowed[path]; ok {
continue
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("解析 %s 失败: %v", path, err)
}
ast.Inspect(f, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.SelectorExpr:
// os.Stdout / os.Stderr
if id, ok := v.X.(*ast.Ident); ok && id.Name == "os" &&
(v.Sel.Name == "Stdout" || v.Sel.Name == "Stderr") {
pos := fset.Position(v.Pos())
offenders = append(offenders, pos.String()+" 直写 os."+v.Sel.Name)
}
// fmt.Print / Println / Printf(默认写 stdout)
if id, ok := v.X.(*ast.Ident); ok && id.Name == "fmt" {
switch v.Sel.Name {
case "Print", "Println", "Printf":
pos := fset.Position(v.Pos())
offenders = append(offenders, pos.String()+" fmt."+v.Sel.Name+" 写 stdout")
}
}
}
return true
})
}
}
if len(offenders) > 0 {
t.Errorf("工具可达的包里有 %d 处直接写终端——TUI 的 alt-screen 会被打穿。\n"+
"诊断请走 internal/diag(Printf/Println),子进程 stderr 用 diag.Indirect():\n %s",
len(offenders), strings.Join(offenders, "\n "))
}
}