mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 为什么必须做
SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:
replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk
一旦 SDK 侧改成新 module 名,主仓立刻炸:
go: ./third_party/homeagent-sdk@ used for two different module paths
(github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)
Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。
## 一个被证伪的前提
我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。
这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。
## 连带修掉两处
| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |
第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。
## 验证
- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
139 lines
5.2 KiB
Go
139 lines
5.2 KiB
Go
package core
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。
|
||
func appendPlaceholder(msgs []agentAPI.Message, replyOnly bool) []agentAPI.Message {
|
||
msgs = dropContinuationPlaceholders(msgs)
|
||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||
msgs = append(msgs, agentAPI.Message{Role: "user", Content: continuationFor(replyOnly)})
|
||
}
|
||
return msgs
|
||
}
|
||
|
||
func countPlaceholders(msgs []agentAPI.Message) int {
|
||
n := 0
|
||
for _, m := range msgs {
|
||
if isContinuationPlaceholder(m) {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 占位是核心插入的传输层附加物,不是用户发言——它不能随轮次线性累积。
|
||
//
|
||
// 旧实现每轮无条件追加而从不移除,跑 N 轮 prompt 里就叠了 N 条一模一样的
|
||
// “继续”,把前缀上下文(含记忆注入)往后挤。
|
||
func TestPlaceholderDoesNotAccumulate(t *testing.T) {
|
||
msgs := []agentAPI.Message{{Role: "user", Content: "用户请求"}}
|
||
|
||
const rounds = 20
|
||
for turn := 0; turn < rounds; turn++ {
|
||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: "调用工具"})
|
||
msgs = append(msgs, agentAPI.Message{Role: "tool", Content: "结果"})
|
||
|
||
// 交替普通工具轮 / 纯发送轮,确保两种文案都参与去重。
|
||
msgs = appendPlaceholder(msgs, turn%2 == 1)
|
||
|
||
if n := countPlaceholders(msgs); n != 1 {
|
||
t.Fatalf("第 %d 轮后占位数=%d,期望恰好 1 条(旧实现会累积到 %d 条)", turn+1, n, turn+1)
|
||
}
|
||
}
|
||
|
||
// 末尾那一轮是纯发送轮,留下的应是“允许收尾”的文案。
|
||
if last := msgs[len(msgs)-1]; last.Content != replyDeliveredPlaceholder {
|
||
t.Fatalf("最后应是回复已交付的文案,实际: %q", last.Content)
|
||
}
|
||
}
|
||
|
||
// 首轮 system/真实用户输入结尾不补位:补了会覆盖实际用户输入。
|
||
func TestPlaceholderNotAppendedOnFirstTurn(t *testing.T) {
|
||
msgs := []agentAPI.Message{
|
||
{Role: "system", Content: "系统说明"},
|
||
{Role: "user", Content: "真实用户输入"},
|
||
}
|
||
got := appendPlaceholder(msgs, false)
|
||
if len(got) != 2 {
|
||
t.Fatalf("首轮不应补位,得到 %d 条: %+v", len(got), got)
|
||
}
|
||
if got[1].Content != "真实用户输入" {
|
||
t.Fatalf("真实用户输入被覆盖: %q", got[1].Content)
|
||
}
|
||
}
|
||
|
||
// 内容相近的真实用户消息不能被当作占位删掉。
|
||
func TestDropOnlyExactPlaceholder(t *testing.T) {
|
||
msgs := []agentAPI.Message{
|
||
{Role: "user", Content: continuationPlaceholder + "补充"},
|
||
{Role: "user", Content: replyDeliveredPlaceholder + "补充"},
|
||
{Role: "user", Content: continuationPlaceholder},
|
||
}
|
||
got := dropContinuationPlaceholders(msgs)
|
||
if len(got) != 2 {
|
||
t.Fatalf("只应删掉精确匹配的那条,得到 %d 条: %+v", len(got), got)
|
||
}
|
||
}
|
||
|
||
// 纯输出通道调用之后的补位不能再是「请继续」。
|
||
//
|
||
// 异步通道的回复只能经 output_send__* 交付,所以模型「已完成回复」的形式就是
|
||
// 一个工具调用;紧跟一句「请继续」会被读成「还要再做一步」,而能做的
|
||
// 「一步」恰好还是再发一条消息。(生产实测:单轮 34 次发送、514 秒)
|
||
func TestContinuationForReplyDoesNotPushToContinue(t *testing.T) {
|
||
reply := continuationFor(true)
|
||
if reply == continuationPlaceholder {
|
||
t.Fatal("回复已交付后不应再补「请继续」,会驱动重复发送")
|
||
}
|
||
if !strings.Contains(reply, "纯文本") || !strings.Contains(reply, "结束") {
|
||
t.Fatalf("应明确告知可返回纯文本收尾,实际: %q", reply)
|
||
}
|
||
|
||
if got := continuationFor(false); got != continuationPlaceholder {
|
||
t.Fatalf("普通工具轮补位应保持不变,实际: %q", got)
|
||
}
|
||
}
|
||
|
||
// 只有真正的发送动作算「交付回复」;_help 是查询用法。
|
||
func TestIsOutputDeliveryTool(t *testing.T) {
|
||
cases := map[string]bool{
|
||
"output_send__qq": true,
|
||
"output_send__webui": true,
|
||
"output_send__qq_help": false,
|
||
"output_list_channels": false,
|
||
"cmd_run": false,
|
||
"qq_get_message": false,
|
||
}
|
||
for name, want := range cases {
|
||
if got := isOutputDeliveryTool(name); got != want {
|
||
t.Errorf("isOutputDeliveryTool(%q) = %v, want %v", name, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 插件在 before_toolcall 里给出的拒绝理由必须原样进入工具结果。
|
||
// 若被通用文案覆盖,模型不知道「为什么被拒 / 能不能重试」,会反复重试同一个调用。
|
||
func TestDenialReasonReachesModel(t *testing.T) {
|
||
reason := "QQ 权限策略拒绝私人资源工具 calendar_list;请不要重试"
|
||
ctx := &sdk.StageContext{Response: &reason}
|
||
if got := denialResultText(ctx, "calendar_list"); got != reason {
|
||
t.Fatalf("拒绝理由被丢弃,实际: %q", got)
|
||
}
|
||
|
||
// 插件没给理由时退回通用文案(保持既有行为)。
|
||
if got := denialResultText(&sdk.StageContext{}, "calendar_list"); !strings.Contains(got, "已被插件拒绝") {
|
||
t.Fatalf("无理由时应退回通用文案,实际: %q", got)
|
||
}
|
||
// 空白理由不算理由。
|
||
blank := " "
|
||
if got := denialResultText(&sdk.StageContext{Response: &blank}, "x"); !strings.Contains(got, "已被插件拒绝") {
|
||
t.Fatalf("空白理由应退回通用文案,实际: %q", got)
|
||
}
|
||
}
|