Files
HomeAgent/internal/agent/core/toolerror_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

180 lines
7.6 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
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 阶段 1b:诚实化 Success。
//
// 现状(task.go:757):`Success: true` 是**唯一**赋值点 ⇒ 该字段恒真,
// 结构上不可能为 false。而工具失败是以 `nil` error + 错误**值**返回的
// (`files/plugin.go:228` 的 `errorResult(...)`、pluginmgr 的 `{"error":…}, nil`)。
//
// 本判据钉死「哪些返回值算失败」。**最大回归风险**在此:
// 判据若只认「error 键」而不认「普通 map/string 仍算成功」,
// 升级就会把存量插件的**成功**误判成失败。
// 失败形态在仓内有**三种**约定(已核实,见各出处):
//
// ① {"error": msg} —— pluginmgr、cmd 的参数校验
// ② {"isError": true, "content": msg} —— files、clawhubadapter 的 errorResult
// ③ {"status":"timeout", "stdout":…, "error":…} —— cmd 超时(带真实数据,status 才是判据)
func TestIsToolError_RecognizesLegacyFailureShapes(t *testing.T) {
failures := []struct {
name string
val interface{}
}{
{"①error 键", map[string]interface{}{"error": "name is required"}},
{"①error 键+其他字段", map[string]interface{}{"error": "boom", "stdout": "partial"}},
{"②isError", map[string]interface{}{"isError": true, "content": "path is required"}},
{"②isError=false 不算失败", map[string]interface{}{"isError": false, "content": "ok"}},
}
for _, c := range failures {
want := c.name != "②isError=false 不算失败"
if got := isToolError(c.val); got != want {
t.Errorf("%s: isToolError = %v,期望 %v(值 %#v)", c.name, got, want, c.val)
}
}
}
// 成功形态**绝不能**被判成失败——这是升级的头号回归风险。
func TestIsToolError_SuccessShapesAreNotFailures(t *testing.T) {
successes := []struct {
name string
val interface{}
}{
{"cmd 成功(含 stderr,命令本身常报 stderr 但不是工具失败)", map[string]interface{}{
"status": "ok", "stdout": "out", "stderr": "warn: deprecated", "exit_code": 0,
}},
{"普通 map", map[string]interface{}{"count": 3, "items": []interface{}{"a"}}},
{"空 map", map[string]interface{}{}},
{"字符串", "已通过 [webui] 通道发送"},
{"ok 标记", "ok"},
// ⚠️ 最高风险的一条:成功的**文本里恰好含 error 字样**。
// 若判据用 strings.Contains(x, "error") 之类,成功就会被判成失败——
// 而 cmd_run 的 stderr、检索到的日志片段都可能含这个词。
{"成功文本含 error 字样", "已处理 3 个 error 日志(命令退出码 0)"},
{"成功文本含 isError 字样", `{"isError": false, "note": "已检查"}`},
{"nil", nil},
{"bool", true},
{"数字", 42},
{"数组", []interface{}{"a", "b"}},
}
for _, c := range successes {
if isToolError(c.val) {
t.Errorf("成功形态被误判为失败: %s(%#v)", c.name, c.val)
}
}
}
// 非零退出码:cmd 的 `exit_code != 0` 属**业务失败**而非工具故障。
// 但它带 `status: "ok"` 与真实 stdout——不应整条判为失败,
// 否则「命令跑了但返回非零」会被误当成工具不可用。
// ⇒ 本判据锁定当前语义:**只看显式错误标记**,不看 exit_code。
func TestIsToolError_NonZeroExitIsNotToolFailure(t *testing.T) {
v := map[string]interface{}{"status": "ok", "stdout": "", "stderr": "boom", "exit_code": 1}
if isToolError(v) {
t.Errorf("非零退出码不应整条判为工具失败(它带真实 stdout/stderr): %#v", v)
}
}
// 显式 ToolError 结构必须被识别(阶段 1a 的新形态)。
func TestIsToolError_RecognizesStructuredToolError(t *testing.T) {
if !isToolError(newToolError(ErrReasonRequired, "path", "缺少 path", "先传 path")) {
t.Error("结构化 ToolError 应被识别为失败")
}
}
// 端到端:失败工具的 Success 必须是 false,成功工具必须是 true。
//
// 这是阶段 1b 的**真正目标**——此前 `Success: true` 是唯一赋值点,
// 恒真、结构上不可能为 false。本判据经 after_toolcall stage 直接读
// f.StageCtx.ToolResults,验证内核产出的值本身,而非某个辅助函数。
func TestStageCtxSuccessIsHonestEndToEnd(t *testing.T) {
cases := []struct {
name string
toolRet interface{}
wantSucc bool
wantHint string // 期望出现在回填文本里的片段
}{
{"成功返回 ok", "ok", true, ""},
{"成功返回结构化 map", map[string]interface{}{"status": "ok", "exit_code": 0}, true, ""},
{"①error 约定失败", map[string]interface{}{"error": "name is required"}, false, "name is required"},
{"②isError 约定失败", map[string]interface{}{"isError": true, "content": "path is required"}, false, "path is required"},
{"结构化 ToolError 失败", newToolError(ErrReasonRequired, "path", "缺少 path", "先传 path 参数"), false, "先传 path 参数"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_ret", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}}
a, _ := newBatchAgent(t, sp)
// 覆盖设备工具的返回值为本用例的样本。
a.io.RegisterDevice(&retDevice{name: "retdev", toolName: "tool_ret", ret: c.toolRet})
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
// 阶段 2c 起结果写在**每个工具自己的** ctx 上(不再回写 f.StageCtx),
// 因此这里按批索引取对应那份——判据跟着结构走,但断言的仍是
// **内核产出的 Success 值本身**。
if len(f.toolCtxs) == 0 {
t.Fatal("toolCtxs 为空(per-tool ctx 未建立)")
}
var tr sdk.ToolResult
found := false
for i := range f.toolCtxs {
if len(f.toolCtxs[i].ToolResults) > 0 {
tr = f.toolCtxs[i].ToolResults[0]
found = true
break
}
}
if !found {
t.Fatal("各工具 ctx 的 ToolResults 全为空")
}
if tr.Success != c.wantSucc {
t.Errorf("Success = %v,期望 %v(返回值 %#v)", tr.Success, c.wantSucc, c.toolRet)
}
if c.wantHint != "" {
// 结构化失败的 Hint 必须真的回填给模型,否则「看得懂真因」落空。
found := false
for _, m := range f.Msgs {
if m.Role == "tool" && strings.Contains(m.Content, c.wantHint) {
found = true
}
}
if !found {
t.Errorf("失败详情 %q 未回填给模型", c.wantHint)
}
}
})
}
}
// retDevice 是一个按预设值返回的测试设备。
type retDevice struct {
name string
toolName string
ret interface{}
}
func (d *retDevice) Name() string { return d.name }
func (d *retDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *retDevice) Description() string { return "ret test device" }
func (d *retDevice) Tools() []agentIO.ToolDef { return []agentIO.ToolDef{{Name: d.toolName}} }
func (d *retDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return d.ret, nil
}
func (d *retDevice) Start() error { return nil }
func (d *retDevice) Stop() error { return nil }
func (d *retDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *retDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }