Files
homeagent-sdk/example/qq/status_mapping_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

192 lines
8.5 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`")
}
}
// ★★★ 判据 8:状态归位(在线)**只能由 turn 边界触发**,不能由单次发送触发。
//
// after_output 被两条路径共用(2026-10-18):
//
// output.go 单次 output_send__*(Extra 有 output_meta)
// eventloop 一轮的回复(turn 边界)
//
// 一轮可能发多条消息。若每条发完都归位「在线」,状态会在干活期间
// 反复跳回空闲 —— "通过状态展示工作进度"就失效了。
func TestOnlineResetOnlyOnTurnBoundary(t *testing.T) {
lines := codeLines(t, "status.go")
body := funcBody(t, lines, "func (p *Plugin) stageAfterOutput(")
// 必须有区分两条路径的分支
if !strings.Contains(body, "isSingleSendStage(ctx)") {
t.Fatal("★ stageAfterOutput 没有区分「单次发送」与「turn 边界」——\n" +
" 两条路径共用同一个 stage,不区分会让单次发送也归位「在线」")
}
// 单次发送分支必须在归位之前 return(不能继续往下走到 applyStatusForce)
iBranch := strings.Index(body, "if isSingleSendStage(ctx) {")
iOnline := strings.Index(body, "applyStatusForce(qqStatusOnline)")
if iBranch < 0 || iOnline < 0 {
t.Fatal("找不到分支或状态归位调用")
}
branch := ifBlockAt(body, iBranch)
if !strings.Contains(branch, "return") {
t.Fatal("★ 单次发送分支没有 return —— 会继续走到状态归位")
}
if strings.Contains(branch, "applyStatusForce") {
t.Fatal("★ 单次发送分支里归位了「在线」—— 一轮发多条消息时状态会反复跳回空闲")
}
// isSingleSendStage 的判据键必须是 output_meta(内核 output.go 放的)
helper := funcBody(t, lines, "func isSingleSendStage(")
if !strings.Contains(helper, `ctx.Extra["output_meta"]`) {
t.Fatal("★ isSingleSendStage 没认 output_meta —— 与内核 output.go 的契约不符")
}
}