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。
This commit is contained in:
JianFeeeee
2026-10-18 18:01:00 +08:00
parent c6c39aa2a8
commit cca6fdaa6b
3 changed files with 279 additions and 24 deletions

View File

@ -176,40 +176,38 @@ func TestTypingWiredToOutputStages(t *testing.T) {
// 定位 stageBeforeOutput / stageAfterOutput 的函数体
bodyOf := func(name string) string {
start := -1
for i, ln := range lines {
if strings.HasPrefix(ln, "func (p *Plugin) "+name+"(") {
start = i
break
}
}
if start < 0 {
t.Fatalf("找不到 %s —— 判据需同步", name)
}
for j := start + 1; j < len(lines); j++ {
if lines[j] == "}" {
return strings.Join(lines[start:j+1], "\n")
}
}
t.Fatalf("%s 函数体没闭合", name)
return ""
// ★ 用 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 没有结束输入指示(stopTypingForTurn)")
t.Fatal("★ after_output 的 turn 边界分支没有结束输入指示(stopTypingForTurn)")
}
// after_output 必须先停指示再清理 auth —— auth 清理若失败,
// turn 边界分支必须先停指示再清理 auth —— auth 清理若失败,
// 指示不能永久留在"正在输入"。
iStop := strings.Index(after, "stopTypingForTurn")
iAuth := strings.Index(after, "afterOutputAuthContext")
if iStop < 0 || iAuth < 0 || iStop > iAuth {
t.Fatal("★ after_output 里应先停输入指示、再清理 auth")
t.Fatal("★ turn 边界分支里应先停输入指示、再清理 auth")
}
}