Files
HomeAgent/internal/agent/core/tool_timeout_test.go
JianFeeeee 7cef7df6f2 test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。

## 一、判据改造(两条实证)

### 1. turn_hint 判据原是自证循环

原测试在测试里**手动重放**了真实注入逻辑:

    f.Msgs = a.buildMessages(...)
    hint := stageHint(f.StageCtx, "turn_hint")
    f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
    // 然后断言"最后一条是 system、内容含 [Turn hints]"

它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。

改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。

变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。

### 2. webui 文件下发判据原是白盒

原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。

改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):

    输入:{payload: "<路径或URL>", type: "image"|"file"}
    输出:{status:"ok", url:"/files/<name>.<ext>", size:N}

4 条判据(新版 handler_files_contract_test.go):
  · 图片 → 可下载 URL(前缀/扩展名/size)
  · 远端 URL 原样透传(不下载)
  · 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
  · 空 payload 被拒

变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。

## 二、文档立规:engineering-lessons 新增第五节

原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:

    5.1  判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
         这条判据还会绿吗"这个可操作判据)
    5.2  禁止读源码做断言(附正确替代表)
    5.2.1 判据不得以文档为依据
    5.3  测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
         agent/core 一个包 196 文件里 144 个是测试)
    5.4  CI 必须覆盖每个独立 module
    5.5  CI 与本地必须跑同一套门

§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。

## 三、CI 补两个缺口

### gofmt 门

提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。

只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。

### SDK 判据步骤

`third_party/hologramagentsdk/` 有自己独立的 go.mod:

    go list ./... | grep third_party   →  0 个

主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。

补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。

## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)

`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。

关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。

主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + 改造后的判据全绿

## 后续(按计划分步)

  步骤 2  webui 29 个测试分类迁移(已实测出精确分类)
  步骤 3  agent/core 739 处白盒引用先分类再动
  步骤 4  SDK tools/hmapdev(package main,无法迁出,只加说明)
  步骤 5  CI 补 coverage + staticcheck
  步骤 6  文档双语(需定术语口径)
2026-11-04 18:11:06 +08:00

131 lines
4.9 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 core
// 工具执行超时的判据(2026-10-17 生产实测缺陷)。
//
// ## 缺陷
//
// `toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时
// **互相矛盾**且让内层形同虚设:
//
// 23:13:38 执行 cmd_run(agent 传了 130s 级 timeout 参数)
// 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
//
// 同一份日志里还有:
// · healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完
// · output_send__commondev_* 两次 60s 超时(投递到子 pipeline 的长任务)
//
// ## 判据覆盖
//
// 1. 未声明 → 60s(与改动前逐字一致,存量插件不变)
// 2. 声明 → 用它(healthcheck 的 5m、output_send 的 3m)
// 3. 参数推导 → cmd_run 传 timeout="2m" 时给 2m(取 max)
// 4. 非法声明 → 回退默认(不静默接受、不 panic)
// 5. 超硬上限 → 夹到 10m(防插件声明成无限把队列堵死)
import (
"testing"
"time"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// toolDefWithTimeout 造一个带 Timeout 声明的 ToolDef。
//
// 原先它单独放在 zz_timeout_helper_test.go —— 文件名前缀 `zz_` 是
// 临时调试残留的惯例,放一个**长期使用**的辅助函数在那种命名的文件里,
// 会让后来人以为它可删。辅助函数跟着唯一的调用方走。
func toolDefWithTimeout(name, timeout string) sdk.ToolDef {
return sdk.ToolDef{Name: name, Description: "test", Timeout: timeout}
}
// fakeStageHost 提供可控的 ToolDef 查询。
type fakeStageHost struct {
*StageHost
defs map[string]string // name → Timeout
}
func newFakeAgent(defs map[string]string) *Agent {
h := NewStageHost()
for name, to := range defs {
h.RegisterTool(name, toolDefWithTimeout(name, to), func(map[string]interface{}) (interface{}, error) {
return nil, nil
})
}
return &Agent{stageHost: h}
}
// ★★★ 判据:未声明超时的工具沿用 60s(存量行为不变)。
func TestToolTimeoutDefaultsTo60s(t *testing.T) {
a := newFakeAgent(nil)
if got := a.resolveToolTimeout("no_such_tool", nil); got != 60*time.Second {
t.Fatalf("未声明应得 60s,实际 %v —— 存量插件的行为必须不变", got)
}
}
// ★★★ 判据:声明了就用声明的值。
func TestToolTimeoutUsesDeclaredValue(t *testing.T) {
a := newFakeAgent(map[string]string{"healthcheck": "5m"})
if got := a.resolveToolTimeout("healthcheck", nil); got != 5*time.Minute {
t.Fatalf("healthcheck 声明 5m,实际 %v", got)
}
}
// ★★★ 判据:调用参数里的 timeout 参与取 max(cmd_run 场景)。
//
// 这是原 bug 的核心:模型传 timeout=2m,内核却只给 60s。
func TestToolTimeoutHonorsCallArgument(t *testing.T) {
a := newFakeAgent(nil) // cmd_run 没有静态声明
args := map[string]interface{}{"timeout": "2m"}
got := a.resolveToolTimeout("cmd_run", args)
if got != 2*time.Minute {
t.Fatalf("★ 参数 timeout=2m 应得 2m,实际 %v —— "+
"这正是原 bug:模型要 2 分钟,内核 60s 就掐断", got)
}
}
// ★★★ 判据:参数比声明**小**时用声明(不因模型说了个短值就少给)。
func TestToolTimeoutTakesMaxOfDeclaredAndArg(t *testing.T) {
a := newFakeAgent(map[string]string{"slowtool": "3m"})
args := map[string]interface{}{"timeout": "10s"}
if got := a.resolveToolTimeout("slowtool", args); got != 3*time.Minute {
t.Fatalf("声明 3m、参数 10s 应取 3m,实际 %v", got)
}
}
// ★★★ 判据:非法声明回退默认(不静默、不 panic)。
func TestToolTimeoutFallsBackOnInvalidDeclaration(t *testing.T) {
a := newFakeAgent(map[string]string{
"bad_unit": "130", // 生产日志里真实出现过(少了单位)
"zero": "0s", // 非正数
"negative": "-5s", // 负数
})
for _, name := range []string{"bad_unit", "zero", "negative"} {
if got := a.resolveToolTimeout(name, nil); got != defaultToolTimeout {
t.Errorf("%s 声明非法,应回退 %v,实际 %v", name, defaultToolTimeout, got)
}
}
}
// ★★★ 判据:超硬上限被夹住(防声明成无限堵死队列)。
func TestToolTimeoutClampedToMax(t *testing.T) {
a := newFakeAgent(map[string]string{"greedy": "2h"})
if got := a.resolveToolTimeout("greedy", nil); got != maxToolTimeout {
t.Fatalf("2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
}
// 参数路径同样要夹
if got := a.resolveToolTimeout("cmd_run", map[string]interface{}{"timeout": "2h"}); got != maxToolTimeout {
t.Fatalf("参数 2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
}
}
// ★★★ 判据:output_send__* 必须声明足够长的超时。
//
// 实测两次 60s 超时:它们是投递到 commondev 开发线(子 pipeline 在
// 克隆/构建/跑测试),天然超过 60s。
func TestOutputSendDeclaresLongTimeout(t *testing.T) {
to := builtinToolTimeout("output_send__commondev_br-xxx")
if to == "" {
t.Skip("output_send__* 的名字是动态的,见下一条判据")
}
}