Files
homeagent-sdk/example/qq/typing_test.go
JianFeeeee cca6fdaa6b fix(qq): 打字指示器挂到正确路径 + 状态限流改为补发而非丢弃
用户反馈两处仍然不对:
「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。
2026-10-18 18:01:00 +08:00

325 lines
12 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)
//
// 旧实现两处都错位:
//
// 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" +
" 「对方正在输入中」会静默失效(编译不报错、内核不告警)")
}
}