mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 22:37:25 +00:00
用户反馈两处仍然不对: 「qq插件的通过在线状态展示agent工作状态还是不正确」 「正在输入中显示也不正确」 ## 缺陷 1:打字指示器一次都没触发过 诊断日志给出了决定性证据(加临时探针跑真实私聊): [qq-dbg] before_output: input_source="" qq_uid=<nil>(has=false) extra_keys=0 `extra_keys=0` —— 说明触发的是**另一条路径的 before_output**。 `before_output` 被两个语义共用: emitResponse(eventloop.go) = 一轮的回复,turn 边界 output.go = 单次 output_send__* 的工具输出 **真正把消息发出去的是后者**,而它构造 stageCtx 时**连 Extra 都没初始化** (nil)。我把打字指示器挂在它上面,读了永远不存在的 qq_uid。 修法:内核 output.go 的 stageCtx.Extra 补三个键 output_channel 目标通道 output_meta 本次发送的 meta 原文(user_id / group_id) output_type text / image / file 插件据此在**消息发出前** startTyping,发出后 stopTyping。 群聊不发(QQ 的「正在输入」只在私聊标题栏有意义)。 ## 缺陷 2:output.go 路径没有 after_output 它才是"消息真的送达"的时刻,却只发了 before_output。没有配对的收尾, 指示器只能等 30 秒超时自己灭。补上 after_output。 ★ 但 after_output 现在被两条路径共用,必须区分: 单次发送 → stopTypingAfterOutput(配对的收尾) turn 边界 → stopTypingForTurn + auth 清理 + **状态归位「在线」** 状态归位**只能由 turn 边界触发**:一轮可能发多条消息,若每条发完都归位 「在线」,状态会在干活期间反复跳回空闲 —— 那正是"用状态展示工作进度" 失效的原因之一。 ## 缺陷 3:限流把期望状态丢掉了 `applyStatusMaybe` 命中 2 秒限流时直接 `return`,**期望状态被丢弃**。 而 stage 之间常常不到 2 秒: pre_action(学习中) --0.5s--> before_toolcall(搬砖中) ⇒ "搬砖中" 被丢 状态是「当前工作阶段」的**投影**,不是事件流 —— 投影必须最终收敛到最新值。 改为:限流只限制发送频率,被推迟的记进 `pending`,窗口结束后补发**最新**值 (不是补发被拒的那个:窗口内可能连来多个,中间值没有独立意义)。 `restoreStatus` 停机时先取消待补发,否则定时器会在连接拆除后醒来。 ## 实测(真实私聊 webhook) 17:58:15 对方正在输入中 → 开始(uid=2198972886,消息发出前) 17:58:15 对方正在输入中 → 结束(uid=2198972886,消息已发出) 状态序列体现出工作阶段: 17:58:05 学习中(限流后补发) 17:58:07 搬砖中 17:58:09 学习中(限流后补发) 17:58:15 搬砖中 17:58:19 在线(turn 边界,任务结束) ## 判据 新增 TestOnlineResetOnlyOnTurnBoundary(变异咬:单次发送也归位 → 报错)。 ★ 修了判据自身的假绿:typing_test 的 bodyOf 用"找行首 }"截函数体, stageAfterOutput 新增 if 分支后,第一个行首 } 是**内层块**的结尾 ⇒ 判据只看到前半段 ⇒ 后半段的调用"看不见"、删掉也不咬。 改用括号计数的 funcBody。
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package main
|
||
|
||
// 「对方正在输入中」的语义判据。
|
||
//
|
||
// ## 修的是什么(2026-10-18)
|
||
//
|
||
// 旧实现两处都错位:
|
||
//
|
||
// HTTP 入口 → startTyping(evt.UserID) ← 消息刚到就挂指示
|
||
// output_send__* 里 → if userID>0 { stopTyping } ← 任何发送都停
|
||
//
|
||
// 具体害处:
|
||
//
|
||
// 1. **起点太早**:agent 还没接受消息、可能在批量合并窗口里等、甚至可能
|
||
// 被权限门拒掉。被拒的那条消息会让用户一直看着"正在输入"却没有回复。
|
||
// 而且 typingLoop 有 30 秒超时 —— agent 跑得慢就自己灭了,回复还在路上。
|
||
//
|
||
// 2. **终点太宽**:stopTyping 挂在"发送消息"上,而发送可能是群聊、可能是
|
||
// 发给别人。正在跟 A 私聊时 agent 发了条群消息,A 的指示被莫名关掉。
|
||
//
|
||
// 正确边界:`before_output` 开始、`after_output` 结束(内核里这两个阶段
|
||
// **每轮只触发一次**),且只处理"自己引起的、有身份的"那一轮。
|
||
//
|
||
// ## 为什么需要内核透传 inject_meta
|
||
//
|
||
// `before_output` 的 stageCtx 是内核新建的(emitResponse),与 `on_input`
|
||
// 的 stageCtx 不共享 Extra(后者是局部变量)。所以身份必须由注入方声明、
|
||
// 内核原样透传 —— 否则插件只能读全局 p.auth 猜,并发私聊会串号
|
||
// (A 的输入指示打到 B 身上)。
|
||
|
||
import (
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
// codeLines 返回某个 .go 文件里**非注释**的代码行。
|
||
//
|
||
// 这个项目已多次栽在「注释里有、代码里没有」:判据直接 Contains 全文,
|
||
// 会把解释性的注释当成实现在,变异删掉代码也不咬。
|
||
func codeLines(t *testing.T, file string) []string {
|
||
t.Helper()
|
||
b, err := os.ReadFile(file)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var out []string
|
||
inBlock := false
|
||
for _, ln := range strings.Split(string(b), "\n") {
|
||
s := strings.TrimSpace(ln)
|
||
if inBlock {
|
||
if i := strings.Index(s, "*/"); i >= 0 {
|
||
s = strings.TrimSpace(s[i+2:])
|
||
inBlock = false
|
||
} else {
|
||
continue
|
||
}
|
||
}
|
||
if strings.HasPrefix(s, "//") || strings.HasPrefix(s, "*") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(s, "/*") {
|
||
if i := strings.Index(s, "*/"); i >= 0 {
|
||
s = strings.TrimSpace(s[i+2:])
|
||
} else {
|
||
inBlock = true
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// funcBody 用**括号计数**提取某个顶层函数/方法的函数体(含签名行)。
|
||
//
|
||
// 为什么不用正则:调用点里常有嵌套括号(`p.injectInterrupt(a, b, qqInjectPayload(x, y, z))`),
|
||
// `[^)]*` 会在第一个 `)` 提前截断 —— 这个判据第一版就因此误报"没带身份"。
|
||
// 也不用 `\n}\n`:函数体内有块语句,第一个行首 `}` 未必是函数结尾。
|
||
func funcBody(t *testing.T, lines []string, sigPrefix string) string {
|
||
t.Helper()
|
||
start := -1
|
||
for i, ln := range lines {
|
||
if strings.HasPrefix(ln, sigPrefix) {
|
||
start = i
|
||
break
|
||
}
|
||
}
|
||
if start < 0 {
|
||
t.Fatalf("找不到 %s —— 判据需同步", sigPrefix)
|
||
}
|
||
depth := 0
|
||
seenBrace := false
|
||
for j := start; j < len(lines); j++ {
|
||
for _, r := range lines[j] {
|
||
switch r {
|
||
case '{':
|
||
depth++
|
||
seenBrace = true
|
||
case '}':
|
||
depth--
|
||
}
|
||
}
|
||
if seenBrace && depth == 0 {
|
||
return strings.Join(lines[start:j+1], "\n")
|
||
}
|
||
}
|
||
t.Fatalf("%s 函数体没闭合", sigPrefix)
|
||
return ""
|
||
}
|
||
|
||
// callArgs 提取 `name(` 之后到**匹配的** `)` 之间的实参文本(处理嵌套括号)。
|
||
func callArgs(s, name string) []string {
|
||
var out []string
|
||
for i := 0; ; {
|
||
k := strings.Index(s[i:], name+"(")
|
||
if k < 0 {
|
||
return out
|
||
}
|
||
k += i + len(name) + 1
|
||
depth := 1
|
||
for j := k; j < len(s); j++ {
|
||
switch s[j] {
|
||
case '(':
|
||
depth++
|
||
case ')':
|
||
depth--
|
||
if depth == 0 {
|
||
out = append(out, s[k:j])
|
||
i = j + 1
|
||
goto next
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
next:
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 1:HTTP 入口**不再**发起输入指示。
|
||
//
|
||
// 变异方式:把 `p.startTyping(evt.UserID)` 加回入口 —— 必咬。
|
||
func TestTypingNotStartedAtHTTPEntry(t *testing.T) {
|
||
for _, ln := range codeLines(t, "plugin.go") {
|
||
if strings.Contains(ln, "startTyping(") && !strings.HasPrefix(ln, "func ") {
|
||
// 允许出现在 startTypingForTurn 里(那是 before_output 的调用)
|
||
t.Fatalf("★ HTTP 入口仍在发起输入指示:%q\n"+
|
||
" 应在 before_output 发起(消息刚到就挂指示会让被拒/排队的消息"+
|
||
"一直显示\"正在输入\")", ln)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 2:发送消息路径**不再**停输入指示。
|
||
//
|
||
// 变异方式:把 `p.stopTyping(userID)` 加回 sendMessage —— 必咬。
|
||
func TestTypingNotStoppedBySending(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
// 只看发送消息那条路径的函数体(handleChannelOutput 是 output_send__* 的落点)。
|
||
//
|
||
// ★ 不能看全文件:`typingLoop` 超时自停是合法的
|
||
// (`p.stopTyping(ts.userID)`),它必须保留 —— 否则 agent 卡死时
|
||
// 指示器永远不会消失。这个判据第一版就误报了这一处。
|
||
body := funcBody(t, lines, "func (p *Plugin) handleChannelOutput(")
|
||
if strings.Contains(body, "stopTyping(") {
|
||
t.Fatal("★ 发送消息仍在停输入指示 ——\n" +
|
||
" 发送可能是群聊/发给别人;正在跟 A 私聊时发条群消息就会关掉 A 的指示")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 3:before_output 发起、after_output 结束(且是真调用,不是注释)。
|
||
func TestTypingWiredToOutputStages(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
|
||
// 定位 stageBeforeOutput / stageAfterOutput 的函数体
|
||
bodyOf := func(name string) string {
|
||
// ★ 用 funcBody(括号计数),不能用"找行首 }":
|
||
// 函数体内有 if/for 块,第一个行首 `}` 往往是**内层块**的结尾,
|
||
// 于是判据看到的是被截断的前半段 —— 后半段里的调用"看不见",
|
||
// 删掉也不咬(实测:stageAfterOutput 新增 if 分支后本判据假绿)。
|
||
return funcBody(t, lines, "func (p *Plugin) "+name+"(")
|
||
}
|
||
|
||
before := bodyOf("stageBeforeOutput")
|
||
if !strings.Contains(before, "p.startTypingForTurn(ctx)") {
|
||
t.Fatal("★ before_output 没有发起输入指示(startTypingForTurn)")
|
||
}
|
||
// after_output 现在有**两条分支**(2026-10-18):
|
||
//
|
||
// 单次 output_send → stopTypingAfterOutput(配对的收尾)
|
||
// turn 边界 → stopTypingForTurn + auth 清理 + 状态归位
|
||
//
|
||
// 两条都必须存在:只留一条会让另一种路径的指示器收不了尾。
|
||
after := bodyOf("stageAfterOutput")
|
||
if !strings.Contains(after, "p.stopTypingAfterOutput(ctx)") {
|
||
t.Fatal("★ after_output 没有为单次发送收尾(stopTypingAfterOutput)——" +
|
||
"agent 用 output_send__qq 直接发消息时,打字指示器会一直挂着")
|
||
}
|
||
if !strings.Contains(after, "p.stopTypingForTurn(ctx)") {
|
||
t.Fatal("★ after_output 的 turn 边界分支没有结束输入指示(stopTypingForTurn)")
|
||
}
|
||
|
||
// turn 边界分支必须先停指示再清理 auth —— auth 清理若失败,
|
||
// 指示不能永久留在"正在输入"。
|
||
iStop := strings.Index(after, "stopTypingForTurn")
|
||
iAuth := strings.Index(after, "afterOutputAuthContext")
|
||
if iStop < 0 || iAuth < 0 || iStop > iAuth {
|
||
t.Fatal("★ turn 边界分支里应先停输入指示、再清理 auth")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 4:只认**注入方声明**的身份(inject_meta),不读全局 p.auth。
|
||
//
|
||
// 读 p.auth 会在并发私聊时串号:A 的消息进来后内核可能先跑 B 那一轮,
|
||
// before_output 读 p.auth 拿到 B ⇒ A 的输入指示打到 B 身上。
|
||
func TestTypingUsesInjectMetaNotGlobalAuth(t *testing.T) {
|
||
lines := codeLines(t, "status.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) turnTypingUser(")
|
||
|
||
if !strings.Contains(body, `ctx.Extra["qq_uid"]`) {
|
||
t.Fatal("★ turnTypingUser 没读 payload 透传的 qq_uid —— 身份必须由注入方声明," +
|
||
"否则并发私聊会串号")
|
||
}
|
||
if strings.Contains(body, "p.auth") {
|
||
t.Fatal("★ turnTypingUser 读了全局 p.auth —— 并发私聊时 A 的输入指示会打到 B 身上")
|
||
}
|
||
// 只处理自己引起的轮次
|
||
if !strings.Contains(body, `ctx.Extra["input_source"]`) {
|
||
t.Fatal("★ turnTypingUser 没校验 input_source —— 别人的输出不该动我们的指示器")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 5:注入时声明了身份(否则 inject_meta 是空的,前面全白搭)。
|
||
func TestInjectDeclaresTypingIdentity(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
joined := strings.Join(lines, "\n")
|
||
|
||
// qqInjectPayload 群聊返回 nil(QQ 的"正在输入"只在私聊标题栏有意义)
|
||
body := funcBody(t, lines, "func qqInjectPayload(")
|
||
if !strings.Contains(body, "isGroup") {
|
||
t.Fatal("★ qqInjectPayload 没排除群聊 —— 群的标题栏没有\"正在输入\"语义")
|
||
}
|
||
|
||
// 所有 injectInterrupt 调用点都必须把 meta 传进去。
|
||
//
|
||
// ★ 用括号计数取实参,不用正则:实参里有嵌套调用
|
||
// `qqInjectPayload(userID, groupID, ...)`,`[^)]*` 会提前截断导致误报。
|
||
calls := callArgs(joined, "p.injectInterrupt")
|
||
if len(calls) == 0 {
|
||
t.Fatal("找不到 injectInterrupt 调用")
|
||
}
|
||
for _, c := range calls {
|
||
if !strings.Contains(c, "qqInjectPayload") {
|
||
t.Fatalf("★ injectInterrupt 调用没带上身份(payload):(%s)\n"+
|
||
" 不带 meta ⇒ before_output 拿不到 inject_meta ⇒ 指示器永远不出现", c)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 6:inject_meta 经 JSON 往返后 user_id 可能是 float64 / json.Number。
|
||
//
|
||
// 只处理 int64 会让真实链路(proc 协议是 JSON)静默失效 ——
|
||
// 单测里传 int64 是绿的,生产里永远不发指示。这是最阴的一类。
|
||
func TestTurnTypingUserAcceptsJSONNumbers(t *testing.T) {
|
||
p := &Plugin{name: "qq"}
|
||
mk := func(v interface{}) *sdk.StageContext {
|
||
ctx := &sdk.StageContext{Extra: map[string]interface{}{
|
||
"input_source": "qq",
|
||
"qq_uid": v,
|
||
}}
|
||
return ctx
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
v interface{}
|
||
want int64
|
||
}{
|
||
{"float64(JSON 解码默认)", float64(1456128421), 1456128421},
|
||
{"int64", int64(1456128421), 1456128421},
|
||
{"int", 1456128421, 1456128421},
|
||
{"nil", nil, 0},
|
||
{"字符串不算", "1456128421", 0},
|
||
}
|
||
for _, c := range cases {
|
||
if got := p.turnTypingUser(mk(c.v)); got != c.want {
|
||
t.Errorf("★ %s: user_id=%v 解析出 %d,期望 %d", c.name, c.v, got, c.want)
|
||
}
|
||
}
|
||
|
||
// 非本插件来源:不该动我们的指示器
|
||
ctx := &sdk.StageContext{Extra: map[string]interface{}{
|
||
"input_source": "other",
|
||
"qq_uid": float64(1),
|
||
}}
|
||
if got := p.turnTypingUser(ctx); got != 0 {
|
||
t.Errorf("★ 别人的轮次也被识别成本插件:%d", got)
|
||
}
|
||
// 无 payload 字段:不发
|
||
if got := p.turnTypingUser(&sdk.StageContext{Extra: map[string]interface{}{}}); got != 0 {
|
||
t.Errorf("★ 无 inject_meta 时不该发指示:%d", got)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据 7:set_input_status 的 user_id 必须是**字符串**。
|
||
//
|
||
// NapCat 4.8.x 接受数字,但 4.18.37 的 payloadSchema 声明成 Type.String。
|
||
// 传数字会报 `uid is empty`(不是 schema 错误 —— 更容易误判成"用户不存在"),
|
||
// 功能**静默失效**:编译不报错、内核不告警、用户只是永远看不到"正在输入"。
|
||
//
|
||
// 实测:
|
||
//
|
||
// {"user_id":1,"event_type":1} → uid is empty
|
||
// {"user_id":"1456128421","event_type":1} → retcode 0, errMsg success
|
||
func TestSetInputStatusSendsStringUserID(t *testing.T) {
|
||
lines := codeLines(t, "plugin.go")
|
||
body := funcBody(t, lines, "func (p *Plugin) setInputStatus(")
|
||
if !strings.Contains(body, `strconv.FormatInt(userID, 10)`) {
|
||
t.Fatal("★ set_input_status 的 user_id 没转成字符串 ——\n" +
|
||
" NapCat 4.18.37 的 payloadSchema 是 Type.String,传数字返回 `uid is empty`,\n" +
|
||
" 「对方正在输入中」会静默失效(编译不报错、内核不告警)")
|
||
}
|
||
}
|