mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 20:27:30 +00:00
refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口
## 用户裁定(本次唯一方向)
1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」
## 实测起点
537 个测试文件 / 377 个源码文件 / 2853 条 Test
523 / 537(97%)直接调用私有函数
514 个是 in-package 白盒(package xxx,与源码同目录)
88 个读 .go/.md 源码做断言
94 个断言私有字段(f.Step / r.buf / a.sched …)
38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
internal/agent/core: 140 test / 52 src
## 做了什么
### ① 删除 514 个 in-package 白盒测试
不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。
用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:
internal/agent/core/child/test/ Registry 的 Add/Get/Stop/Remove 输出
internal/agent/core/test/ StageHost 的 Register/Run 输出
internal/plugins/files/test/ 工具 handler 的 {path} → {content/error}
internal/plugins/webui/test/ HTTP 请求 → 响应(NewHandler/RegisterRoutes)
internal/tfidf/test/ 文本 → 向量/检索结果
现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。
### ② 回退「为测试而导出」的接口
这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:
webui.FrontendAsset / DashboardPage 删除
webui.GraphDataForVisual → graphDataForVisual
webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
webui.NewSSEEventRing 删除
kstate.MountForTest 删除
security.ClassifyPrincipalForTest 删除
生产代码对它们的内部调用同步改回私有名,不改任何行为。
### ③ CI 一鼓作气改到位(用户指定顺序)
**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。
**加两道构建期静态门**(原先这些约束错放在 go test 里):
- `测试归拢(源码目录不得有 _test.go)`
`find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
这是**工具链**的职责,不该由 `go test` 兼职。
`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。
### ④ 顺带(上一批未提交的)
`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
python3 .github/scripts/check-ci-gates.py PASS
go test ./... -count=1 5 个测试包全过 / 100 个包无测试 / 0 FAIL
源码目录 _test.go 数 0
测试文件数 9(全部在 <module>/test/)
## 后续
按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
This commit is contained in:
@ -1,95 +0,0 @@
|
||||
//go:build linux || windows
|
||||
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// eventCapture 订阅事件总线,收下 terminal_output 事件供断言。
|
||||
type eventCapture struct {
|
||||
mu sync.Mutex
|
||||
outs []map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *eventCapture) add(ev *events.Event) {
|
||||
c.mu.Lock()
|
||||
c.outs = append(c.outs, ev.Payload)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// outputFor 汇总某个终端已发布的全部 output 片段。
|
||||
func (c *eventCapture) outputFor(id string) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var s string
|
||||
for _, p := range c.outs {
|
||||
if p["terminal_id"] == id {
|
||||
if o, _ := p["output"].(string); o != "" {
|
||||
s += o
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *eventCapture) statesFor(id string) []bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []bool
|
||||
for _, p := range c.outs {
|
||||
if p["terminal_id"] == id {
|
||||
r, _ := p["running"].(bool)
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 短命令(在首个 200ms ticker 之前就结束)的输出必须在退出时补推,
|
||||
// 否则只留在 buf 里、永远发不出 terminal_output 事件,
|
||||
// 内核权威视图(以及 WebUI/CLI 的 /terminals)output 恒为空。
|
||||
func TestReadLoopFlushesOutputOnExit(t *testing.T) {
|
||||
p := New("agentcli")
|
||||
bus := events.NewBus()
|
||||
capture := &eventCapture{}
|
||||
bus.Subscribe(events.EventTerminalOutput, capture.add)
|
||||
|
||||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||||
RegTool: func(string, sdk.ToolDef, sdk.ToolHandler) error { return nil },
|
||||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||||
RegAPI: func(string) error { return nil },
|
||||
Settings: sdk.NewSettings("agentcli", nil),
|
||||
EventBus: bus,
|
||||
})
|
||||
sdkInst.SetIOInjector(&injectCapture{})
|
||||
|
||||
term := newMockTerm()
|
||||
ts := newTestSession(term)
|
||||
startReadLoop(p, sdkInst, ts)
|
||||
|
||||
// 推入输出后立刻让进程退出——远早于 200ms ticker。
|
||||
term.push([]byte("HELLO_KERNEL_REGISTRY\n"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
term.setRunning(false)
|
||||
|
||||
select {
|
||||
case <-ts.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("readLoop did not exit")
|
||||
}
|
||||
|
||||
if got := capture.outputFor(ts.id); got != "HELLO_KERNEL_REGISTRY\n" {
|
||||
t.Fatalf("output on exit = %q, want %q", got, "HELLO_KERNEL_REGISTRY\n")
|
||||
}
|
||||
// 停止状态也要报到(readLoop 退出时 emitTermState(false))。
|
||||
states := capture.statesFor(ts.id)
|
||||
if len(states) == 0 || states[len(states)-1] {
|
||||
t.Fatalf("expected trailing running=false, got %v", states)
|
||||
}
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 超时终端必须真正释放:杀掉整个进程组(包括 sh 的子进程如 sleep)
|
||||
// 且回收子进程(不留 <defunct>)。
|
||||
//
|
||||
// 旧实现三个缺陷叠加:
|
||||
// 1. readLoop 的 IsExpired 分支只 delete(sessions) 后 return,不 Kill 不 Close;
|
||||
// 2. Kill 只杀直接子进程 sh,Setsid 后真正的命令(sleep)是孙进程,成为孤儿;
|
||||
// 3. 只 Start 从不 Wait,退出的子进程无人回收,积成僵尸。
|
||||
func TestExpiredTerminalReleasesProcessGroup(t *testing.T) {
|
||||
p := New("agentcli")
|
||||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||||
RegTool: func(string, sdk.ToolDef, sdk.ToolHandler) error { return nil },
|
||||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||||
RegAPI: func(string) error { return nil },
|
||||
Settings: sdk.NewSettings("agentcli", nil),
|
||||
})
|
||||
sdkInst.SetIOInjector(&injectCapture{})
|
||||
if err := p.Start(sdkInst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
// 命令设计要点(缺一不可):
|
||||
// 1) 让 sh 保留为父进程、另起孙进程(`... & wait`)——单个 `sleep 300`
|
||||
// 会被 sh 直接 exec 掉,只有一个进程,测不到「孙进程逃逸」;
|
||||
// 2) 孙进程显式忽略 SIGHUP——否则关 PTY master 时内核发的 SIGHUP 会
|
||||
// 顺手把它带走,于是「只杀 leader」也能通过,测不出进程组 Kill 的必要性。
|
||||
// 两个条件合起来,只有给整个进程组发 SIGKILL 才能清干净。
|
||||
marker := `(trap "" HUP; sleep 300) & wait`
|
||||
res, err := p.handleCreate(sdkInst, map[string]interface{}{
|
||||
"command": marker,
|
||||
"timeout": "1s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := res.(map[string]interface{})
|
||||
if m["error"] != nil {
|
||||
t.Skipf("PTY unavailable: %v", m["error"])
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
ts := p.sessions[m["id"].(string)]
|
||||
p.mu.Unlock()
|
||||
if ts == nil {
|
||||
t.Fatal("terminal not registered")
|
||||
}
|
||||
pid := ts.cmd.Process.Pid
|
||||
|
||||
// 等超时被 readLoop 处理(含 Kill 进程组 + Wait 回收)
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !procAlive(pid) && !procGroupAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expired terminal not released: leader pid=%d alive=%v groupAlive=%v",
|
||||
pid, procAlive(pid), procGroupAlive(pid))
|
||||
}
|
||||
|
||||
func procAlive(pid int) bool { return syscall.Kill(pid, 0) == nil }
|
||||
|
||||
// procGroupAlive 检查「原进程组」里是否还有活着的成员(含被 init 收养的孙进程)。
|
||||
//
|
||||
// 不能用 kill(-pgid, 0):组领头进程一死,内核就可能回收该 pgid,
|
||||
// 即便组里还有被 reparent 的成员,这个探测也会失败。
|
||||
// 改为直接遍历 /proc 查 pgid 匹配的活进程。
|
||||
func procGroupAlive(pgid int) bool {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if pid, err := strconv.Atoi(e.Name()); err == nil {
|
||||
if readPgid(pid) == pgid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readPgid 从 /proc/<pid>/stat 读进程组 id(第 5 个字段)。
|
||||
// stat 的 comm 字段可能含空格/括号,所以从最后一个 ')' 之后再切分。
|
||||
func readPgid(pid int) int {
|
||||
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
s := string(data)
|
||||
i := strings.LastIndex(s, ")")
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
fields := strings.Fields(s[i+1:])
|
||||
if len(fields) < 3 {
|
||||
return -1
|
||||
}
|
||||
// fields[0]=state, [1]=ppid, [2]=pgrp
|
||||
pgid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return pgid
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
//go:build linux || windows
|
||||
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// Stop() 必须在有活跃终端时也能返回:它先 p.wg.Wait() 再 Close 终端,
|
||||
// 而 readLoop 自己也记在 p.wg 上且不监听 p.stopCh —— 若 readLoop 只在
|
||||
// t.stopCh 上阻塞,p.wg.Wait() 会永远等下去(死锁)。
|
||||
func TestStopWithActiveTerminal(t *testing.T) {
|
||||
p := New("agentcli")
|
||||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||||
RegTool: func(string, sdk.ToolDef, sdk.ToolHandler) error { return nil },
|
||||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||||
RegAPI: func(string) error { return nil },
|
||||
Settings: sdk.NewSettings("agentcli", nil),
|
||||
})
|
||||
sdkInst.SetIOInjector(&injectCapture{})
|
||||
if err := p.Start(sdkInst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
term := newMockTerm()
|
||||
ts := newTestSession(term)
|
||||
ts.command = "sleep 999"
|
||||
p.mu.Lock()
|
||||
p.sessions[ts.id] = ts
|
||||
p.mu.Unlock()
|
||||
startReadLoop(p, sdkInst, ts)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { p.Stop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Stop() deadlocked with an active terminal")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须幂等:重复调用不能 panic(close 已关闭的 channel 会 panic)。
|
||||
func TestStopIsIdempotent(t *testing.T) {
|
||||
p := New("agentcli")
|
||||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||||
RegTool: func(string, sdk.ToolDef, sdk.ToolHandler) error { return nil },
|
||||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||||
RegAPI: func(string) error { return nil },
|
||||
Settings: sdk.NewSettings("agentcli", nil),
|
||||
})
|
||||
sdkInst.SetIOInjector(&injectCapture{})
|
||||
if err := p.Start(sdkInst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Stop() panicked on repeated call: %v", r)
|
||||
}
|
||||
}()
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Stop(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,71 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNewCommandPtyConPTY 验证 Windows ConPTY 后端:一次性命令输出可读,
|
||||
// 交互式会话可写读往返。
|
||||
func TestNewCommandPtyConPTY(t *testing.T) {
|
||||
ta, _, err := newCommandPty("cmd.exe /c echo conpty-ok", 24, 80)
|
||||
if err != nil {
|
||||
t.Fatalf("once: %v", err)
|
||||
}
|
||||
outA := drainFor(ta, 3*time.Second)
|
||||
if !strings.Contains(string(outA), "conpty-ok") {
|
||||
t.Fatalf("once output missing echo: %q", string(outA))
|
||||
}
|
||||
ta.Close()
|
||||
|
||||
tb, _, err := newCommandPty("cmd.exe", 24, 80)
|
||||
if err != nil {
|
||||
t.Fatalf("interactive: %v", err)
|
||||
}
|
||||
defer tb.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := tb.WriteString("echo hi-123\r\n"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
outB := drainFor(tb, 3*time.Second)
|
||||
if !strings.Contains(string(outB), "hi-123") {
|
||||
t.Fatalf("interactive output missing echo: %q", string(outB))
|
||||
}
|
||||
if !tb.Running() {
|
||||
t.Fatalf("interactive shell should still be running")
|
||||
}
|
||||
}
|
||||
|
||||
func drainFor(term ptyTerm, dur time.Duration) []byte {
|
||||
deadline := time.Now().Add(dur)
|
||||
buf := make([]byte, 4096)
|
||||
var out []byte
|
||||
for time.Now().Before(deadline) {
|
||||
ch := make(chan struct {
|
||||
N int
|
||||
E error
|
||||
}, 1)
|
||||
go func() {
|
||||
n, e := term.Read(buf)
|
||||
ch <- struct {
|
||||
N int
|
||||
E error
|
||||
}{n, e}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.N > 0 {
|
||||
out = append(out, buf[:r.N]...)
|
||||
}
|
||||
if r.E != nil {
|
||||
return out
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||||
)
|
||||
|
||||
// 编译期断言:agentcli 必须实现 plugin.SkillProvider,
|
||||
// 否则内核的 InstallBuiltinSkill 会因类型断言失败而**静默跳过** ——
|
||||
// 模型永远看不到这份文档,而工具照样存在。
|
||||
//
|
||||
// 这个断言本身就是判据:删掉 BuiltinSkills 方法 ⇒ 编译失败。
|
||||
var _ plugin.SkillProvider = (*Plugin)(nil)
|
||||
|
||||
func TestSkillProviderContract(t *testing.T) {
|
||||
p := New("agentcli")
|
||||
skills := p.BuiltinSkills()
|
||||
if len(skills) == 0 {
|
||||
t.Fatal("BuiltinSkills 返回空 —— 模型永远看不到 agentcli 的使用说明")
|
||||
}
|
||||
body, ok := skills[builtinSkillName]
|
||||
if !ok {
|
||||
t.Fatalf("缺 %q 条目", builtinSkillName)
|
||||
}
|
||||
if len(body) < 500 {
|
||||
t.Fatalf("SKILL 正文过短(%d 字节),不足以讲清终端与一次性命令的边界", len(body))
|
||||
}
|
||||
// frontmatter 的 name 必须与 map 的键一致(内核按它建目录)。
|
||||
if !strings.Contains(body, "name: "+builtinSkillName) {
|
||||
t.Fatalf("frontmatter 的 name 必须等于 %q(否则 directory 名与索引对不上)", builtinSkillName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSeparatesTerminalFromOneShot 是本文件最重要的判据。
|
||||
//
|
||||
// ## 它守的是什么(真实生产事故)
|
||||
//
|
||||
// 用户报告:agent 把 terminal_create 当成一次性命令执行器用。日志实录:
|
||||
//
|
||||
// terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "..." && ls -d */ | head -60 && …')
|
||||
// → read /dev/ptmx: input/output error
|
||||
// → 再开 term_7、term_8、term_9 …
|
||||
//
|
||||
// 那份文档必须同时说清三件事,缺任何一条都会让模型退回旧行为:
|
||||
//
|
||||
// ① 一次性命令该用 cmd_run(并说明 cmd 插件要先 plugin_start)
|
||||
// ② 终端的真正用途是"要持续输入的程序"
|
||||
// ③ 复合命令也属于"一次性",不是"所以要用终端"
|
||||
//
|
||||
// **特别是第 ③ 条**:`command` 这个参数名天然诱导模型往里面塞
|
||||
// `cd && echo && grep`。文档必须正面抵消这个引力。
|
||||
func TestSkillSeparatesTerminalFromOneShot(t *testing.T) {
|
||||
body := New("agentcli").BuiltinSkills()[builtinSkillName]
|
||||
|
||||
musts := []struct {
|
||||
substr string
|
||||
why string
|
||||
}{
|
||||
{"cmd_run", "必须点名一次性命令该用 cmd_run,否则模型只知道有终端"},
|
||||
{"plugin_start", "必须说明 cmd 插件是按需加载(auto_start=false),否则模型找不到 cmd_run 就用终端"},
|
||||
{"vim", "必须给出终端的正面用例(vim/ssh/top 这类要持续输入的程序)"},
|
||||
{"&&", "必须点明复合命令(cd && echo && ls)属于一次性,不是终端的理由"},
|
||||
{"跑完就结束", "必须给出可操作的判据,而不是只说「交互式/非交互式」这种抽象词"},
|
||||
{"/dev/ptmx", "必须解释那个真实报错的含义(命令跑完终端已退出),否则模型会以为终端坏了"},
|
||||
}
|
||||
for _, m := range musts {
|
||||
if !strings.Contains(body, m.substr) {
|
||||
t.Fatalf("SKILL 缺少 %q ——\n%s", m.substr, m.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillIndexLineStatesBoundary 钉住索引行(模型每轮唯一能看到的一行)。
|
||||
//
|
||||
// 索引行是 `[Skills]` 段里的那条摘要。如果它只写"交互式终端",
|
||||
// 模型无从知道"一次性命令别用我"——而那正是要拦的行为。
|
||||
func TestSkillIndexLineStatesBoundary(t *testing.T) {
|
||||
body := New("agentcli").BuiltinSkills()[builtinSkillName]
|
||||
|
||||
// 取 frontmatter 的 description(这是内核 extractDescription 的
|
||||
// 主要来源之一;另一条路是正文首段,见下一个判据)。
|
||||
var desc string
|
||||
lines := strings.Split(body, "\n")
|
||||
inFM := false
|
||||
for i, l := range lines {
|
||||
t := strings.TrimSpace(l)
|
||||
if i == 0 && t == "---" {
|
||||
inFM = true
|
||||
continue
|
||||
}
|
||||
if inFM {
|
||||
if t == "---" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(t, "description:") {
|
||||
desc = strings.TrimSpace(strings.TrimPrefix(t, "description:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if desc == "" {
|
||||
t.Fatal("frontmatter 缺 description —— 索引行会退化成正文首段")
|
||||
}
|
||||
if !strings.Contains(desc, "cmd_run") {
|
||||
t.Fatalf("索引行必须指出替代方案(cmd_run),实际:%q\n"+
|
||||
"只说「交互式终端」不够:模型看到那一行仍不知道一次性命令该用什么。", desc)
|
||||
}
|
||||
if !strings.Contains(desc, "一次性") {
|
||||
t.Fatalf("索引行必须点明边界(一次性命令别用终端),实际:%q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillBodyFirstParagraphStandalone 钉住正文首段能独立看懂。
|
||||
//
|
||||
// 内核的 extractDescription **跳过 frontmatter**,取正文第一个非空
|
||||
// 非标题段落 —— 所以首段才是真正注入系统提示词的那一行。
|
||||
func TestSkillBodyFirstParagraphStandalone(t *testing.T) {
|
||||
body := New("agentcli").BuiltinSkills()[builtinSkillName]
|
||||
|
||||
lines := strings.Split(body, "\n")
|
||||
inFM, started := false, false
|
||||
first := ""
|
||||
for i, l := range lines {
|
||||
t := strings.TrimSpace(l)
|
||||
if i == 0 && t == "---" {
|
||||
inFM = true
|
||||
continue
|
||||
}
|
||||
if inFM {
|
||||
if t == "---" {
|
||||
inFM, started = false, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if started && t != "" && !strings.HasPrefix(t, "#") {
|
||||
first = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("正文首段为空 —— 索引行会缺失")
|
||||
}
|
||||
// 首段必须自带边界,不能只靠标题或后文。
|
||||
if !strings.Contains(first, "cmd_run") {
|
||||
t.Fatalf("正文首段必须提到 cmd_run(它才是注入提示词的那段),实际:%q", first)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user