4cbc7ebe11
leo21c 畫面同時說「今天已經幫你整理了 0 份」與「額度用完,可以換一個模型, 或升級 Cloudflare」——一份都沒成功,額度其實是被同帳號正在做的向量化 (嵌入固定走 Workers AI,與萃取共用同一份每日免費額度)吃光的,換萃取模型 救不了。改法:buildQuotaNotice 在 dailyCount==0(本輪一份都沒吃到額度就先 撞牆)時換一套出口/保證句——不再建議換模型、不再無條件承諾「明天會自動 接著跑」,只留「升級 Cloudflare」這個結構上真的有效的出口;dailyCount>0 (單純量大用完)維持原三句話不動。 同批修另一則真因已被頂層 wiki 鎖定的假訊息:leo 三個帳號 /health 全部 200, 畫面卻顯示「沒連上雲端」——direct.go 寫 status.json 頂層 cloud_check_ok 時 以前只有剛好一個帳號才會填,2+ 帳號(leo 的常態)時恆為 Go 零值 false。 改成不論帳號數,任一帳號連得上就標頂層為 true。 兩者都有回歸測試:對修改前的程式碼跑會 FAIL(多帳號 CloudCheckOK 案例已 用 git stash 實測驗證),修完後 PASS。額度卡文案另外用真正的 frontend/dist + 假 window.go 灌 leo21c 現場資料,Claude Browser 實際開起來看過,確認新 文案正確渲染、不再出現「可以換一個模型」、console 無紅字。 未做:兩者都只在原始碼層修好,尚未重打 daemon bundle 出貨(0.18.25 用戶 手上還看不到);頂層 cloud_check_ok 的實際消費端(除已知的舊版 tray)未 完全追出,留給下一輪核實。詳見 system-dev/docs/3-specs/daemon-beta/tasks.md t216/t217。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.0 KiB
Go
165 lines
6.0 KiB
Go
// direct_quota_test.go — 額度用完要說人話並降速(2026-08-07 task 2)端到端驗證。
|
||
//
|
||
// 模擬 wiki mistakes.md 2026-08-06 記錄的真實上游狀況:Workers AI 每日免費額度
|
||
// 10,000 neurons 用完,HTTP 502,訊息含「4006: you have used up your daily free
|
||
// allocation of 10,000 neurons」。驗收:畫面(status.json)出現三句話,
|
||
// 沒有裸露的錯誤碼;且同一輪/下一輪不再繼續撞同一面牆。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// quotaExhaustedServer 對任何 /portal/daemon/extract 請求都回上游額度用完的原始錯誤。
|
||
// calls 只計「真的萃取」請求(body.text 非空)——ProbeWorkersAI 每輪固定會探測一次
|
||
// (送空 text,不燒 LLM 額度,見 probe_workersai.go),不是本測試要驗的「有沒有繼續撞牆」。
|
||
func quotaExhaustedServer(t *testing.T, calls *int) *httptest.Server {
|
||
t.Helper()
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Text string `json:"text"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
if strings.TrimSpace(body.Text) != "" {
|
||
*calls++
|
||
}
|
||
w.WriteHeader(http.StatusBadGateway) // 實據:HTTP 502
|
||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||
"error": "4006: you have used up your daily free allocation of 10,000 neurons",
|
||
})
|
||
}))
|
||
}
|
||
|
||
func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
writeFile(t, root, "b.md", "內容 B", baseTime.Add(time.Minute))
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
manifest := filepath.Join(t.TempDir(), "m.json")
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: manifest,
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 {
|
||
t.Fatalf("兩份都該萃取失敗,exit 應為 1,got %d", exit)
|
||
}
|
||
if len(results) != 2 {
|
||
t.Fatalf("results=%+v", results)
|
||
}
|
||
|
||
banned := []string{"4006", "502", "HTTP", "neurons"}
|
||
for _, r := range results {
|
||
for _, b := range banned {
|
||
if strings.Contains(r.Error, b) {
|
||
t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error)
|
||
}
|
||
}
|
||
// arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0——
|
||
// 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice
|
||
// 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型
|
||
// (結構上做不到:嵌入不管選哪個萃取模型都走 Workers AI),只留「升級」這個真出口。
|
||
for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare"} {
|
||
if !strings.Contains(r.Error, want) {
|
||
t.Errorf("缺三句話之一 %q:%q", want, r.Error)
|
||
}
|
||
}
|
||
if strings.Contains(r.Error, "可以換一個模型") {
|
||
t.Errorf("dailyCount==0(這輪一份都沒成功)不該再建議換模型:%q", r.Error)
|
||
}
|
||
}
|
||
|
||
// status.json 也要是人話,同樣不准有裸碼
|
||
st, err := LoadSyncStatus(StatusFilePath(manifest))
|
||
if err != nil {
|
||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||
}
|
||
if st.ExtractorOK {
|
||
t.Error("額度冷卻中,ExtractorOK 應為 false")
|
||
}
|
||
for _, b := range banned {
|
||
if strings.Contains(st.ExtractorError, b) {
|
||
t.Errorf("status.ExtractorError 不該有裸碼 %q:%q", b, st.ExtractorError)
|
||
}
|
||
}
|
||
acc, ok := st.AccountDetails[instanceHostOf(srv.URL)]
|
||
if !ok {
|
||
t.Fatal("找不到帳號的 status")
|
||
}
|
||
if acc.QuotaMessage == nil {
|
||
t.Fatal("應該有 QuotaMessage")
|
||
}
|
||
if acc.QuotaCooldownUntil == "" {
|
||
t.Fatal("應該記下冷卻到什麼時候")
|
||
}
|
||
for _, b := range banned {
|
||
combined := acc.QuotaMessage.Combined()
|
||
if strings.Contains(combined, b) {
|
||
t.Errorf("QuotaMessage 不該有裸碼 %q:%q", b, combined)
|
||
}
|
||
}
|
||
|
||
// 只有第一次真的打了 API(第二份的 xerr 判斷不需要——一偵測到就進冷卻,
|
||
// 第二個事件被上面的 qs.inCooldown 擋下,不再觸網)。
|
||
if calls != 1 {
|
||
t.Fatalf("偵測到額度用完後不該繼續撞牆,got %d 次呼叫", calls)
|
||
}
|
||
}
|
||
|
||
// 同一帳號、下一輪(新的 RunDirectOnce 呼叫,模擬下一次輪詢):冷卻還沒過,
|
||
// 一次網路呼叫都不該再打——這是「不要一次湧上去、也不要一直撞牆」的核心。
|
||
func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 1 {
|
||
t.Fatal("第一輪應該偵測到額度用完")
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("第一輪應該只打 1 次,got %d", calls)
|
||
}
|
||
|
||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||
if exit2 != 0 {
|
||
t.Fatalf("冷卻中的 skip 不是失敗,exit 應為 0,got %d results=%+v", exit2, results2)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("冷卻中不該再打任何一次 API,got %d 次呼叫", calls)
|
||
}
|
||
if len(results2) != 1 || results2[0].Status != "skipped" {
|
||
t.Fatalf("results2=%+v", results2)
|
||
}
|
||
// arcrun-rag#59:這支測試同樣是 dailyCount==0(單一檔案、全程沒有成功過),
|
||
// 跳過訊息改口成「升級 Cloudflare」這個真出口,不再是「自動恢復」的舊三句話。
|
||
if !strings.Contains(results2[0].Error, "今天已經幫你整理了") ||
|
||
!strings.Contains(results2[0].Error, "升級 Cloudflare") {
|
||
t.Errorf("跳過訊息也該是三句話(新版):%q", results2[0].Error)
|
||
}
|
||
}
|