Files
homeagent-sdk/example/qq/status_mapping_test.go
JianFeeeee c6c39aa2a8 feat(qq): Qzone 说说、群发硬拦截、@全体不算@bot、状态语义修正
## 1. Qzone 说说(send_qzone_msg / delete_qzone_msg)

一次真实的误判,值得记下来:我曾断言「生产 NapCat 不支持发说说」,
依据是探测返回「不支持的Api SendQzoneMsg」——**是我的调用名错了**。
router.ts 里声明的是下划线式 `send_qzone_msg`,我用的是源码**类名**
`SendQzoneMsg`。换对名字后一次成功。

教训:「API 不存在」这个结论必须用真实 action 名验证;源码里的
类名/文件名/方法名都不是调用名。判据 TestQzoneActionNamesAreSnakeCase
把这条钉住了。

本地校验 ugc_right 白名单(1/4/16/64/128)与 16/128 必带 target_uins,
省一次跨进程往返、给模型更直接的反馈。

## 2. 内部委派不得向群聊发送(现场事故)

offload 子 agent 的「分诊汇报」把本该私聊给用户的进度播报**发到了群里**:

	17:15:41 executing tool: output_send__qq
	17:15:41 handleChannelOutput meta={"group_id": 979911915}
	17:15:42 群友质问「又没@你,你就这么确定是找你的?」

根因:子 agent 被授予全部输出通道(AllowedOutputs=nil,用户明确要求
"持有全部输出通道"),于是它能自己选目标——而它只从上下文里看到一个
群号,就选了群。

修法:`beforeToolcall` 在 internal 放行前调用 outputSendGroupTarget,
识别出群目标就写 denial。为什么不放在 handleChannelOutput:ToolHandler
只收 args,拿不到帧上的 comefrom(SDK 契约如此),只有 stage 帧能看到。

## 3. @全体成员不算 @bot

原实现 `if v == botIDStr || v == "all" { return true }` —— 任何人
@全体都会拉起 bot。那不是"在叫 bot",是"在叫所有人":一个是针对性请求,
一个是广播,语义完全不同。

群里误响应的代价不是"多回一句",而是**在未被邀请的场合发言**。
宁可漏响应(用户会再 @ 一次),不能误响应。

## 4. 状态语义按用户定义重排

	学习中 = 等待 LLM      搬砖中 = 执行 toolcall      在线 = 空闲

	pre_action      → 学习中(stepLLM 之前,唯一能提前标记"要等模型"处)
	before_toolcall → 搬砖中
	after_toolcall  → 学习中(工具回来了,又要等 LLM)
	before_output   → 不改(LLM 已回、即将输出,不是等模型)
	after_output    → 在线(force,绕防抖)

原先「学习中」挂在 before_output 是错的:那个时刻 LLM 已经返回。

## 5. 「对方正在输入中」按轮驱动

起点从 HTTP 入口(消息刚到)改到 before_output(每轮只触发一次),
终点从 output_send(任何发送都停,含群聊/发给别人)改到 after_output。

身份由 InjectOptions.Payload 携带 `qq_uid`,内核透传到 stageCtx.Extra。
不用全局 p.auth:并发私聊会串号(A 的指示打到 B 身上)。

## 6. 启动日志不再硬编码工具数

`log.Printf("... (%d tools)", p.name, 15)` 硬编码了 15,工具加到 24 后
仍报 15 —— 排查"qzone 装上没"时被误导了好几轮。改成真实计数 p.toolCount。

## 判据(新增 13 条,变异 8/8 咬)

Qzone 5 条、群发拦截 5 条、@门 4 条、状态映射 7 条、打字 7 条。

★ 两个判据自身缺陷被变异抓出并修掉:
 · @门判据用字符串找 `}\n\t}` 截分支,而 joined 已 strip 缩进 ⇒ 找不到
   ⇒ 分支延伸到文件末尾 ⇒ "文件里任何别处的 return"都让它通过。
   改用括号计数 ifBlockAt。
 · 状态判据只查常量名,`_ = qqStatusStudy` 就蒙混过关。改查真实调用
   applyStatus(qqStatusStudy)。
2026-10-18 17:39:40 +08:00

152 lines
6.7 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 main
// 状态映射的判据。
//
// ## 用户定义的语义(2026-10-18)
//
// 学习中 = agent 正在**等待 LLM**
// 搬砖中 = 真正**执行 toolcall**
// 在线 = **空闲**
//
// ## 内核时序(task.go,权威)
//
// pre_action ──→ stepLLM() ──→ ★等 LLM★ ──→ post_action
// ↓
// 工具循环: before_toolcall → ★跑工具★ → after_toolcall
// ↓
// 再次 stepLLM(第二轮工具)或 before_output → after_output
//
// 所以正确映射是:
//
// pre_action → 学习中 (即将调 LLM,接下来就是等)
// before_toolcall → 搬砖中 (即将/正在跑工具)
// after_toolcall → 学习中 (工具回来了,又要等 LLM 决定下一步)
// before_output → 不改状态 (LLM 已回、即将输出;不是等 LLM 也不是跑工具)
// after_output → 在线 (一轮真正结束,空闲)
//
// ## 曾经错在哪
//
// 「学习中」被挂在 `before_output` 上 —— 那个时刻 LLM **已经返回**了,
// 等的是输出而不是等模型。后果:等待 LLM 的整段时间状态是错的
// (停在上一轮的「搬砖中」或「在线」),用户看不到 agent 在想。
import (
"strings"
"testing"
)
// ★★★ 判据 1:pre_action 必须切「学习中」(这是唯一的"即将等 LLM"时点)。
func TestStatusStudyOnPreAction(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stagePreAction(")
if !strings.Contains(body, "applyStatus(qqStatusStudy)") {
t.Fatal("★ pre_action 没有切「学习中」——\n" +
" 内核时序里 pre_action 在 stepLLM() **之前**,这是唯一能提前标记\n" +
" “接下来要等模型”的位置。不在这里切,等待 LLM 的整段时间状态都是错的")
}
}
// ★★★ 判据 2:before_toolcall 必须切「搬砖中」。
func TestStatusWorkingOnBeforeToolcall(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stageBeforeToolcall(")
if !strings.Contains(body, "applyStatus(qqStatusWorking)") {
t.Fatal("★ before_toolcall 没有切「搬砖中」")
}
// 权限门必须仍在这里(合并后不能丢)
if !strings.Contains(body, "p.beforeToolcall(ctx)") {
t.Fatal("★ before_toolcall 里权限门丢了 —— 合并时把安全逻辑吞掉了")
}
}
// ★★★ 判据 3:after_toolcall 必须回到「学习中」。
//
// 不回到学习中,状态会在整个工具循环里挂着「搬砖中」,看起来像工具卡住。
func TestStatusStudyOnAfterToolcall(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stageAfterToolcall(")
if !strings.Contains(body, "applyStatus(qqStatusStudy)") {
t.Fatal("★ after_toolcall 没回到「学习中」——\n" +
" 工具回来了,接下来又要等 LLM 决定下一步。不切会让状态\n" +
" 在整个工具循环里挂着「搬砖中」,看起来像卡住")
}
}
// ★★★ 判据 4:before_output **不**改在线状态。
//
// 那个时刻 LLM 已返回、即将输出 —— 既不是等 LLM 也不是跑工具。
// 在那里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想。
func TestStatusNotSetOnBeforeOutput(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stageBeforeOutput(")
// 必须检查**真实调用**:`_ = qqStatusStudy` 这种引用不该算作"改了状态",
// 但 `applyStatus(qqStatusStudy)` 必须算。反过来也一样——
// 只查常量名会让"引用了但没调用"的变异漏网(实测)。
for _, bad := range []string{"applyStatus(qqStatusStudy)", "applyStatus(qqStatusWorking)", "applyStatusForce(qqStatusOnline)"} {
if strings.Contains(body, bad) {
t.Fatalf("★ before_output 改了在线状态(%s)——\n"+
" 这个时刻 LLM 已返回、即将输出,既不是等 LLM 也不是跑工具;\n"+
" 在这里写「学习中」会让状态在收尾时继续挂着,看起来像永远在想", bad)
}
}
// 但它必须仍负责打字指示
if !strings.Contains(body, "p.startTypingForTurn(ctx)") {
t.Fatal("★ before_output 没有发起打字指示")
}
}
// ★★★ 判据 5:after_output 必须是「在线」(force,绕防抖)。
//
// 一轮结束时是唯一的"归位"机会;被防抖拦住就会永远停在错误状态。
func TestStatusOnlineOnAfterOutput(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stageAfterOutput(")
if !strings.Contains(body, "applyStatusForce(qqStatusOnline)") {
t.Fatal("★ after_output 没有 force 归位「在线」——\n" +
" 一轮结束是唯一的归位机会;用 applyStatus(带防抖)会被\n" +
" 2 秒间隔拦住,进而永远停在错误状态")
}
}
// ★★★ 判据 6:pre_action / after_toolcall 必须真的注册了。
//
// 光有函数不够 —— 不注册就等于没写。这类“函数在、注册忘”的漏洞
// 编译器不报、运行时不报错。
func TestStatusStagesRegistered(t *testing.T) {
lines := codeLines(t, "plugin.go")
joined := strings.Join(lines, "\n")
for _, s := range []string{
"sdk.StagePreAction, p.stagePreAction",
"sdk.StageAfterToolcall, p.stageAfterToolcall",
} {
if !strings.Contains(joined, s) {
t.Fatalf("★ 没有注册 stage:%s(函数写了但没注册 = 没写)", s)
}
}
}
// ★★★ 判据 7:启动日志里的工具数必须是**真实计数**,不能写死。
//
// 这里原本是 `log.Printf("... (%d tools)", p.name, 15)` —— 硬编码。
// 后来工具从 15 加到 24,日志仍报 15。
//
// 后果不是"日志难看",而是**排查时被误导**:我查"qzone 工具装上没",
// 看到日志说 15 tools,以为装的是旧版,白折腾了好几轮。
// 日志说谎比不日志更坏 —— 因为它看起来像证据。
func TestToolCountLoggedIsNotHardcoded(t *testing.T) {
for _, ln := range codeLines(t, "plugin.go") {
if !strings.Contains(ln, "[qq] plugin started:") {
continue
}
if !strings.Contains(ln, "p.toolCount") {
t.Fatalf("★ 启动日志没报真实工具数:%q\n"+
" 硬编码的数字会随工具增减而失真,进而误导排查", ln)
}
}
// 计数必须真的在注册时累加(否则 p.toolCount 恒为 0,比硬编码更糟)
body := funcBody(t, codeLines(t, "plugin.go"), "func (p *Plugin) regTool(")
if !strings.Contains(body, "p.toolCount++") {
t.Fatal("★ regTool 没有累加 p.toolCount —— 计数恒为 0,日志会报 `0 tools`")
}
}