Files
HomeAgent/internal/plugins/webui/chat_ack_timeout_test.go
JianFeeeee 134cd860b6 fix(webui): 聊天受理超时 15s 太短,误报「已取消(用户停止)」
## 内测用户报告

发一句话,界面回「已取消(用户停止)」,而且连着出现多次。

## 实测真相(用户提供的日志)

    20:58:36  POST /api/v1/chat 开始,agent 开始干活
    20:58:48  agent 仍在跑(terminal_create / terminal_read)
    20:58:52  ★ 前端 15s ackTimer 到点 → ctrl.abort()
              → 服务端记 status=504,耗时 15.89s
    20:59:29  ★ agent 正常跑完,52.5 秒、22 个工具、返回 response

也就是说:**agent 从来没有被取消过**。它一直在正常干活并跑完了,
只是前端自己掐断了连接,回复没法送回,UI 就把"我没等到"显示成了
"用户停止"——一个用户从没做过的操作。

## 根因

`sendChat()` 里把"受理确认"的等待写死 15 秒,而 agent 的正常任务
动辄 45-55 秒(工具越多越久;同一份日志里另一次是 45262ms)。

这个 POST 本来就是**触发式**的:真正渲染靠 SSE 流式(`armTurnWatchdog`
兜 120 秒)。在受理环节抢时间没有任何意义,只会误伤正常任务。

## 改动

1. **超时 15s → 600s**:不再用固定短超时。保留上限只为兜住
   "连 TCP 都建不起来"这类真故障,避免请求永远挂着。

2. **文案改掉**:原来 abort 后提示「请求超时(可能已发送)」,
   红字 error 样式。用户看到"超时"会以为消息丢了而**重发** ——
   实测会造成同一句话产生两条任务。改为:
   「已发出,AI 正在处理(复杂任务可能要一两分钟)。请等待回复,不要重复发送。」
   并且不再用 error 样式(这不是错误)。

## 判据(2 条,源码扫描)

 · `TestChatAckTimeoutIsGenerous`:超时 ≥ 5 分钟
   (实测正常任务 45-55 秒,留足余量)
 · `TestChatAckTimeoutMessageDoesNotBlameFailure`:提示不得出现
   "失败/超时/Error/failed",必须说清"正在处理"且提醒"不要重发"

变异 2/2 咬住(改回 15s / 改回"请求超时"文案都当场变红)。

★ 为什么用源码扫描而不是行为测试:这是前端 abort 时序,没有可注入的
  时钟。扫描至少能防住"有人把 15s 改回来"。
2026-10-17 21:16:49 +08:00

110 lines
4.0 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 webui
// 聊天受理超时的判据(2026-10-17 内测故障)。
//
// ## 故障现象
//
// 用户发一句话,界面回「已取消(用户停止)」。而实际日志显示
// agent **一直在正常干活**,并且跑完了:
//
// 20:58:36 POST /chat 开始
// 20:58:52 ★ 前端 15s ackTimer abort → 服务端 status=504 (15.89s)
// 20:59:29 ★ agent 正常完成,52552ms,22 个工具,返回 response
//
// 也就是说:回复被生成了,但 HTTP 连接已被前端自己掐断 ⇒ 送不回去,
// 用户看到的是"已取消(用户停止)"——一个**用户从没做过的操作**。
//
// ## 根因
//
// 前端 `sendChat()` 里把"受理确认"的等待写死成 15 秒,而 agent 的
// 正常任务动辄 45-55 秒(工具越多越久)。这个 POST 本来就是**触发式**的,
// 真正渲染靠 SSE,没必要在这里抢时间。
//
// ## 判据
//
// 1. 那个超时必须远大于实测的正常任务耗时(不能是 15s 这种量级)
// 2. 超时后的提示不能说成"失败/超时错误",否则用户会重发造成双任务
//
// ★ 为什么用源码扫描而不是行为测试:这是前端 abort 时序,
// 没有可注入的时钟。扫描至少能防住"有人把 15s 改回来"。
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
func dashboardJS(t *testing.T) string {
t.Helper()
// webui 包目录 → 同目录下 dashboard.js
p := filepath.Join("dashboard.js")
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("读 dashboard.js: %v", err)
}
return string(b)
}
// ★★★ 判据:受理超时必须 ≥ 5 分钟。
//
// 实测正常任务 45-55 秒;留 5 分钟以上余量,
// 保证"任务慢"不会被误判成"连接坏了"。
func TestChatAckTimeoutIsGenerous(t *testing.T) {
src := dashboardJS(t)
// 找 ackTimer 的 setTimeout 时长
re := regexp.MustCompile(`var ackTimer = setTimeout\(\(\) => \{\s*ctrl\.abort\(\);\s*\}, (\d+)\)`)
m := re.FindStringSubmatch(src)
if m == nil {
t.Fatal("找不到 ackTimer 的超时值 —— 判据需要更新(代码结构变了?)")
}
ms, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("超时值不是数字: %q", m[1])
}
const minAck = 300000 // 5 分钟
if ms < minAck {
t.Fatalf("★ 受理超时只有 %d ms —— 太小。\n"+
" 实测 agent 正常任务就要 45-55 秒(22 个工具那次 52552ms);\n"+
" 超时太短会让前端 abort,而 agent 仍在正常干活,\n"+
" 结果是用户看到「已取消(用户停止)」——他从没点过停止。", ms)
}
}
// ★★★ 判据:超时提示不得把"还在处理"说成"失败"。
func TestChatAckTimeoutMessageDoesNotBlameFailure(t *testing.T) {
src := dashboardJS(t)
// 定位 ack 失败分支附近的 toast 文案
i := strings.Index(src, "catch (ackErr)")
if i < 0 {
t.Fatal("找不到 ack 失败分支")
}
seg := src[i:]
if end := strings.Index(seg, "} finally {"); end > 0 {
seg = seg[:end]
}
// 不能出现"失败/错误/Error/failed"这类归因
for _, bad := range []string{"请求失败", "Request failed", "请求超时", "Request timeout"} {
if strings.Contains(seg, bad) {
t.Fatalf("★ ack 超时提示用了 %q —— 会让用户以为出错了而重发,"+
" 造成同一句话产生两条任务(实测 agent 其实还在正常跑)", bad)
}
}
// 必须告诉用户"已发出、正在处理、不要重发"
if !strings.Contains(seg, "正在处理") && !strings.Contains(seg, "is working") {
t.Fatal("★ ack 超时提示没说清'AI 正在处理' —— 用户会以为消息丢了")
}
if !strings.Contains(seg, "不要重复发送") && !strings.Contains(seg, "do not resend") {
t.Fatal("★ 没提醒不要重发 —— 重发会造成重复任务")
}
// 不能标成 error(第二个参数 true)
if strings.Contains(seg, ",\n\t\t\t\ttrue,\n\t\t\t);") && !strings.Contains(seg, "false,") {
t.Log("提示: 建议用非 error 样式展示(这不是错误)")
}
}