magic-agent 2.6.0 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +8 -3
|
@@ -44,12 +44,17 @@ package agent
|
|
|
44
44
|
import (
|
|
45
45
|
"regexp"
|
|
46
46
|
"strings"
|
|
47
|
+
"time"
|
|
47
48
|
"unicode/utf8"
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
// limitNoticeMaxRunes 通告那一段的长度上限(一句话;长回答不该被整段吞掉)。
|
|
51
52
|
const limitNoticeMaxRunes = 400
|
|
52
53
|
|
|
54
|
+
// limitNoticeWholeMaxRunes 「整段就是通告、但没给恢复时刻」那一档的长度上限。
|
|
55
|
+
// 它没有「时刻」当分水岭,所以更靠长度挡误伤:真通告都短,正经回答很少整段这么短。
|
|
56
|
+
const limitNoticeWholeMaxRunes = 160
|
|
57
|
+
|
|
53
58
|
var (
|
|
54
59
|
// 起点:句子边界(正文开头 / 换行 / 句末标点 / 冒号)+ 已知状态码 + 一个空格。
|
|
55
60
|
// ⚠️ 刻意**不认「普通空格之后」**:模型正经回答里写「HTTP 429 是限流,额度会在 … 重置」
|
|
@@ -64,12 +69,41 @@ var (
|
|
|
64
69
|
// 排障用的请求 id 尾巴(形如 `(0l1a124d…/0l1a123c3-…)`)—— 它属于通告,
|
|
65
70
|
// 判「整段一句话」时要先剥掉。
|
|
66
71
|
limitNoticeIDTailRe = regexp.MustCompile(`\s*\([^()\n]{4,200}\)\s*$`)
|
|
72
|
+
// 请求 id 的另一种写法(实测 2026-10-25 英文额度通告:`Request id: 0217929113…`)——
|
|
73
|
+
// 它既不是给人看的,也不该进卡片的原文行;剥掉括号尾巴之后再剥它一次。
|
|
74
|
+
limitNoticeReqIDRe = regexp.MustCompile(`(?i)\s*request[\s_-]?id\s*[::]\s*[0-9a-z][0-9a-z-]{6,}\s*$`)
|
|
67
75
|
// 末尾那个句号(可有可无,剥掉后再判「段内还有没有句末标点」)。
|
|
68
76
|
limitNoticeEndRe = regexp.MustCompile(`[。.!!??]\s*$`)
|
|
69
77
|
// 段内的句末标点(出现即说明这段不止一句话 → 不是通告)。
|
|
70
78
|
limitNoticeSeptRe = regexp.MustCompile(`[。!?!?]`)
|
|
79
|
+
|
|
80
|
+
/* 「整段就是通告、但没给恢复时刻」那一档(见 wholeNoticeCore)。它的判据不带时刻,
|
|
81
|
+
所以每一条都独立收紧:状态码必须顶在最前面、措辞必须是「用量真的没了」(不是
|
|
82
|
+
「429 表示限流」这种释义句)、并且必须给出「接下来怎么办」。 */
|
|
83
|
+
limitNoticeCodeHeadRe = regexp.MustCompile(`^(?:429|402|401|403|5\d{2})\s`)
|
|
84
|
+
/* 释义句的形状:「429 是限流」「429 表示…」—— 这是模型在**回答**用户的问题,
|
|
85
|
+
不是供应商在报错。只认状态码开头那一种(别的位置出现「表示」不影响)。 */
|
|
86
|
+
limitNoticeExplainRe = regexp.MustCompile(`(?i)^(?:429|402|401|403|5\d{2})\s*(?:是|就是|表示|指|means|refers)`)
|
|
87
|
+
noticeExhaustRe = regexp.MustCompile(`(?i)用量上限|用量已|额度|余额|欠费|用尽|用完|quota|insufficient|exceeded|balance`)
|
|
88
|
+
noticeExhaustHintRe = regexp.MustCompile(`(?i)升级|购买|充值|续费|补充|开通|重置|切换|稍后|再试|upgrade|purchase|top ?up|recharge|reset|retry|switch`)
|
|
71
89
|
)
|
|
72
90
|
|
|
91
|
+
// stripNoticeTails 剥掉通告末尾的排障尾巴(括号里的请求 id、以及 `Request id: …` 那种写法)
|
|
92
|
+
// 与末尾标点。三条路径(末尾夹通告 / 整段就是通告)都要剥同一套,所以收在一处。
|
|
93
|
+
func stripNoticeTails(s string) string {
|
|
94
|
+
out := strings.TrimSpace(s)
|
|
95
|
+
// 可能连着几条尾巴(实测 402:「…消耗。 (2067) (01a1…/01a1…)」),循环剥干净。
|
|
96
|
+
for i := 0; i < 3; i++ {
|
|
97
|
+
next := strings.TrimSpace(limitNoticeIDTailRe.ReplaceAllString(out, ""))
|
|
98
|
+
next = strings.TrimSpace(limitNoticeReqIDRe.ReplaceAllString(next, ""))
|
|
99
|
+
if next == out {
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
out = next
|
|
103
|
+
}
|
|
104
|
+
return strings.TrimSpace(limitNoticeEndRe.ReplaceAllString(out, ""))
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
// splitLimitNotice 判 text 的**末尾**是不是夹着一句供应商的限流/额度通告。
|
|
74
108
|
//
|
|
75
109
|
// 命中返回 (通告原文, 通告之前真正的正文, true);不命中返回 ("", "", false)。
|
|
@@ -88,7 +122,7 @@ func splitLimitNotice(text string) (notice, body string, ok bool) {
|
|
|
88
122
|
if seg == "" || utf8.RuneCountInString(seg) > limitNoticeMaxRunes {
|
|
89
123
|
continue
|
|
90
124
|
}
|
|
91
|
-
core :=
|
|
125
|
+
core := stripNoticeTails(seg)
|
|
92
126
|
if limitNoticeSeptRe.MatchString(core) {
|
|
93
127
|
continue
|
|
94
128
|
}
|
|
@@ -99,5 +133,177 @@ func splitLimitNotice(text string) (notice, body string, ok bool) {
|
|
|
99
133
|
}
|
|
100
134
|
return strings.Join(strings.Fields(core), " "), strings.TrimRight(s[:at], " \t\r\n\u3000"), true
|
|
101
135
|
}
|
|
136
|
+
// 整段就是通告、但**没给恢复时刻**那一档(2026-10-25,实测 402 原文):
|
|
137
|
+
// 402 当前已达到 Token Plan 用量上限。为避免调用中断,请升级 Token Plan 套餐,或购买积分…
|
|
138
|
+
// 上面那条判据把「有恢复时刻」当分水岭,这类通告因此漏了;而它整轮只有这一段正文,
|
|
139
|
+
// 落到画面上就是「一大段供应商原文 + 已完成」。这里补一条**更严**的窄路(四条同时成立):
|
|
140
|
+
// ① 整段就是它(body 必须为空)—— 不是「末尾夹着」,那就没有「哪句是通告」的歧义;
|
|
141
|
+
// ② 开头就是状态码(前面连正文都没有);
|
|
142
|
+
// ③ 有「用量真的没了」的措辞(noticeExhaustRe)—— 不是「429 是什么意思」那种释义句;
|
|
143
|
+
// ④ 短(≤160 字)。
|
|
144
|
+
if core, hit := wholeNoticeCore(s); hit {
|
|
145
|
+
return core, "", true
|
|
146
|
+
}
|
|
102
147
|
return "", "", false
|
|
103
148
|
}
|
|
149
|
+
|
|
150
|
+
// wholeNoticeCore 上面那条窄路的本体(整段就是一个不带时刻的额度通告)。
|
|
151
|
+
//
|
|
152
|
+
// ⚠️ 这条路**不要求「一句话」**:实测那条 402 原文本身就有两个句号(「…用量上限。为避免
|
|
153
|
+
// 调用中断,请升级…」)。换成四条更硬的约束来挡误伤 —— 它们任何一条都比「句数」更难蒙对:
|
|
154
|
+
// 整段只有它(没有「哪句是通告」的歧义)、开头就是状态码、措辞是「用量真的没了」而不是
|
|
155
|
+
// 「429 表示…」这种释义句、并且短。
|
|
156
|
+
func wholeNoticeCore(s string) (string, bool) {
|
|
157
|
+
core := stripNoticeTails(s)
|
|
158
|
+
if core == "" || utf8.RuneCountInString(core) > limitNoticeWholeMaxRunes {
|
|
159
|
+
return "", false
|
|
160
|
+
}
|
|
161
|
+
if strings.ContainsAny(core, "\n\r") { // 通告只有一段;换行 = 这是个正经回答
|
|
162
|
+
return "", false
|
|
163
|
+
}
|
|
164
|
+
if !limitNoticeCodeHeadRe.MatchString(core) { // 状态码必须在最前面
|
|
165
|
+
return "", false
|
|
166
|
+
}
|
|
167
|
+
if limitNoticeExplainRe.MatchString(core) { // 「429 是限流…」= 在回答,不是通告
|
|
168
|
+
return "", false
|
|
169
|
+
}
|
|
170
|
+
if !noticeExhaustRe.MatchString(core) || !noticeExhaustHintRe.MatchString(core) {
|
|
171
|
+
return "", false
|
|
172
|
+
}
|
|
173
|
+
return strings.Join(strings.Fields(core), " "), true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ── 通告的结构化字段(2026-10-25)────────────────────────────────
|
|
177
|
+
*
|
|
178
|
+
* 由来:用户报障「没额度了,设计一个提示卡」。界面要画一张**额度卡**
|
|
179
|
+
* (什么时候恢复 / 该怎么办),可事件上原来只有 Error / Reason 两段人话原文,
|
|
180
|
+
* 客户端只能靠正则从原文里反推 —— 反推逻辑一旦写错就是错卡,且三端会各写一套。
|
|
181
|
+
* 所以分类与时刻解析收在这里(core 一处),事件带上 error_kind / error_code /
|
|
182
|
+
* reset_at,客户端只做展示。
|
|
183
|
+
*
|
|
184
|
+
* 两档(客户端据此决定标题与建议,不能只有一个「出了错」):
|
|
185
|
+
*
|
|
186
|
+
* quota 额度用尽 / 余额不足 / 套餐用量上限 —— 等恢复或换模型,与代码无关;
|
|
187
|
+
* rate_limit 频率限制(短时窗口)—— 隔一会儿再试即可。
|
|
188
|
+
*
|
|
189
|
+
* 判据刻意保守:认不出来就返回空 kind(客户端退回普通「调用失败」卡),
|
|
190
|
+
* 不硬套标签 —— 把「工具报错」画成「额度不足」比不画更糟。
|
|
191
|
+
*/
|
|
192
|
+
const (
|
|
193
|
+
// ErrorKindQuota 额度用尽(余额 / 套餐用量上限 / quota exhausted)。
|
|
194
|
+
ErrorKindQuota = "quota"
|
|
195
|
+
// ErrorKindRateLimit 频率限制(短时窗口,等一会儿就能再试)。
|
|
196
|
+
ErrorKindRateLimit = "rate_limit"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
var (
|
|
200
|
+
// 额度类措辞(比 limitNoticeNounRe 更宽:通告已经被认出来了,这里只分档)。
|
|
201
|
+
noticeQuotaRe = regexp.MustCompile(`(?i)额度|余额|欠费|quota|insufficient|balance|credit|用量上限|token plan|plan limit|套餐`)
|
|
202
|
+
noticeRateRe = regexp.MustCompile(`(?i)频率限制|限流|rate[\s_-]?limit|frequency limit|too many requests|用量过多`)
|
|
203
|
+
// 状态码(通告开头那几个,与 splitLimitNotice 的起点判据同一套)。
|
|
204
|
+
noticeCodeRe = regexp.MustCompile(`^\s*(\d{3})\b`)
|
|
205
|
+
// 恢复时刻:`2026-10-25 15:07:27` 主体(两种实测格式共用)。
|
|
206
|
+
noticeStampRe = regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?`)
|
|
207
|
+
// 时区偏移:`+0800` / `+08:00` / `UTC+8` 三种拼法(实测都出现过)。
|
|
208
|
+
noticeZoneRe = regexp.MustCompile(`(?i)(?:UTC)?\s*([+-])(\d{1,2}):?(\d{2})?`)
|
|
209
|
+
// ISO 的 Z(= UTC)。必须单独判:`noticeZoneRe` 认的是带符号的偏移,Z 没有符号。
|
|
210
|
+
noticeZuluRe = regexp.MustCompile(`(?i)^\s*Z\b`)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// classifyNotice 把一条已认定的额度/限流通告分成两档,并取出状态码。
|
|
214
|
+
//
|
|
215
|
+
// kind 为空 = 是限流类通告但分不清是哪一档(客户端按普通失败卡处理)。
|
|
216
|
+
// code 为空 = 通告里没有状态码。
|
|
217
|
+
func classifyNotice(text string) (kind, code string) {
|
|
218
|
+
s := strings.TrimSpace(text)
|
|
219
|
+
if s == "" {
|
|
220
|
+
return "", ""
|
|
221
|
+
}
|
|
222
|
+
if m := noticeCodeRe.FindStringSubmatch(s); m != nil {
|
|
223
|
+
code = m[1]
|
|
224
|
+
}
|
|
225
|
+
switch {
|
|
226
|
+
case noticeQuotaRe.MatchString(s):
|
|
227
|
+
kind = ErrorKindQuota
|
|
228
|
+
case noticeRateRe.MatchString(s):
|
|
229
|
+
kind = ErrorKindRateLimit
|
|
230
|
+
}
|
|
231
|
+
/* 402 是「需要付费」的语义码,原文措辞可能只有一句「Payment Required」——
|
|
232
|
+
这种也要算额度,否则用户看到的是一张普通错误卡。 */
|
|
233
|
+
if kind == "" && code == "402" {
|
|
234
|
+
kind = ErrorKindQuota
|
|
235
|
+
}
|
|
236
|
+
return kind, code
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// parseNoticeReset 从通告里取「什么时候恢复」,取不到返回零值时间。
|
|
240
|
+
//
|
|
241
|
+
// ⚠️ 三种拼法都要认(都是本仓库日志里的**实测原文**):
|
|
242
|
+
//
|
|
243
|
+
// 2026-10-25 15:07:27 +0800 CST ← 英文版;偏移无冒号,尾部还有一个**无意义**的 CST
|
|
244
|
+
// 2026-10-25 23:45:51 UTC+8 ← 中文版
|
|
245
|
+
// 2026-10-25T15:07:27Z ← 少数供应商给 ISO 形状
|
|
246
|
+
//
|
|
247
|
+
// ⚠️ **绝不用 Date.parse 那套宽松解析**:V8 会把 `CST` 当美国中部时间,
|
|
248
|
+
// 实测偏 14 小时 —— 界面上就成了「半夜三点恢复」。这里只认显式偏移,
|
|
249
|
+
// 没有偏移就按**本地时区**理解(通告是给本机用户看的,供应商给的钟点也是本地的)。
|
|
250
|
+
func parseNoticeReset(text string) time.Time {
|
|
251
|
+
s := strings.TrimSpace(text)
|
|
252
|
+
m := noticeStampRe.FindStringSubmatch(s)
|
|
253
|
+
if m == nil {
|
|
254
|
+
return time.Time{}
|
|
255
|
+
}
|
|
256
|
+
atoi := func(v string) int {
|
|
257
|
+
n := 0
|
|
258
|
+
for _, c := range v {
|
|
259
|
+
if c < '0' || c > '9' {
|
|
260
|
+
return n
|
|
261
|
+
}
|
|
262
|
+
n = n*10 + int(c-'0')
|
|
263
|
+
}
|
|
264
|
+
return n
|
|
265
|
+
}
|
|
266
|
+
sec := 0
|
|
267
|
+
if m[6] != "" {
|
|
268
|
+
sec = atoi(m[6])
|
|
269
|
+
}
|
|
270
|
+
t := time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
271
|
+
atoi(m[4]), atoi(m[5]), sec, 0, time.Local)
|
|
272
|
+
/* 时区:只在时间戳**紧跟其后**的那一小段里找偏移 —— 全串搜会把
|
|
273
|
+
「Request id: +86…」这类内容当成时区。 */
|
|
274
|
+
rest := strings.TrimSpace(s[strings.Index(s, m[0])+len(m[0]):])
|
|
275
|
+
if rest != "" {
|
|
276
|
+
zonePart := rest
|
|
277
|
+
if i := strings.IndexAny(zonePart, "\n"); i >= 0 {
|
|
278
|
+
zonePart = zonePart[:i]
|
|
279
|
+
}
|
|
280
|
+
if len(zonePart) > 16 {
|
|
281
|
+
zonePart = zonePart[:16]
|
|
282
|
+
}
|
|
283
|
+
if z := noticeZoneRe.FindStringSubmatch(zonePart); z != nil {
|
|
284
|
+
h, min := atoi(z[2]), atoi(z[3])
|
|
285
|
+
offset := h*3600 + min*60
|
|
286
|
+
if z[1] == "-" {
|
|
287
|
+
offset = -offset
|
|
288
|
+
}
|
|
289
|
+
loc := time.FixedZone("", offset)
|
|
290
|
+
t = time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
291
|
+
atoi(m[4]), atoi(m[5]), sec, 0, loc)
|
|
292
|
+
} else if noticeZuluRe.MatchString(zonePart) {
|
|
293
|
+
/* `…T15:07:27Z`(ISO 的 Z = UTC)。别漏了这一支:漏了就会按**本地时区**理解,
|
|
294
|
+
本机在 +08 时整体偏 8 小时(测试就是在这里抓到它的)。 */
|
|
295
|
+
t = time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
296
|
+
atoi(m[4]), atoi(m[5]), sec, 0, time.UTC)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return t
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// noticeResetRFC3339 通告里的恢复时刻(RFC3339);取不到回空串(wire 上是 omitempty)。
|
|
303
|
+
func noticeResetRFC3339(text string) string {
|
|
304
|
+
t := parseNoticeReset(text)
|
|
305
|
+
if t.IsZero() {
|
|
306
|
+
return ""
|
|
307
|
+
}
|
|
308
|
+
return t.Format(time.RFC3339)
|
|
309
|
+
}
|
|
@@ -19,6 +19,7 @@ import (
|
|
|
19
19
|
"path/filepath"
|
|
20
20
|
"strings"
|
|
21
21
|
"testing"
|
|
22
|
+
"time"
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
const (
|
|
@@ -30,6 +31,18 @@ const (
|
|
|
30
31
|
limitNoticeZH = "429 您的使用量已超出频率限制,将在 2026-09-19 17:49:56 UTC+8 重置," +
|
|
31
32
|
"您也可以切换其他模型继续使用。"
|
|
32
33
|
limitNoticeNarration = "Let me read the file's end."
|
|
34
|
+
/* 实测英文额度通告(2026-10-25 用户报障截图里的那一轮原文,会话日志 01a16fea:
|
|
35
|
+
**整段只有它**、前面没有正文、带两个尾巴)。与上面那条 429 的差别是措辞:
|
|
36
|
+
这条说的是「5 小时额度用尽」,上面那条是「频率限制」。 */
|
|
37
|
+
limitNoticeQuotaEN = "429 You have exceeded the 5-hour usage quota. It will reset at " +
|
|
38
|
+
"2026-10-25 15:07:27 +0800 CST. We recommend upgrading your plan for more quota, " +
|
|
39
|
+
"or waiting for the reset. Request id: 021792911321900fd0de7354b348b065f00b895e07126b865f18b" +
|
|
40
|
+
" (01a171d5e11f7519bcf4077dffd2ebad/01a16fea-4a01-789d-b694-3d99c11b3c8b)"
|
|
41
|
+
/* 实测 402 原文(同一批日志里连出七条):**没有恢复时刻**,却整段就是通告。
|
|
42
|
+
它落在画面上就是「一大段供应商原文 + 已完成」—— 上面那条「有恢复时刻」的判据
|
|
43
|
+
因此认不出它(2026-10-25 补的窄路就是为它)。 */
|
|
44
|
+
limitNoticeQuota402 = "402 当前已达到 Token Plan 用量上限。为避免调用中断,请升级 Token Plan 套餐," +
|
|
45
|
+
"或购买积分补充用量并开启积分自动消耗。 (2067) (01a16c13a2e67c05b09eb8b1311f97d8/01a16c05-cabd-7fd0-9fdc-abca97cb92ae)"
|
|
33
46
|
)
|
|
34
47
|
|
|
35
48
|
func TestSplitLimitNoticeRealSamples(t *testing.T) {
|
|
@@ -81,6 +94,43 @@ func TestSplitLimitNoticeRealSamples(t *testing.T) {
|
|
|
81
94
|
}
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
// 2026-10-25 加的两条实测原文:英文 5 小时额度、中文 402(都没在前面挂正文)。
|
|
98
|
+
func TestSplitLimitNoticeQuotaSamples(t *testing.T) {
|
|
99
|
+
notice, body, ok := splitLimitNotice(limitNoticeQuotaEN)
|
|
100
|
+
if !ok {
|
|
101
|
+
t.Fatalf("英文额度通告(截图里的那一轮)没认出来 —— 它会原样铺在气泡里")
|
|
102
|
+
}
|
|
103
|
+
if !strings.HasPrefix(notice, "429 You have exceeded") {
|
|
104
|
+
t.Errorf("notice = %q", notice)
|
|
105
|
+
}
|
|
106
|
+
if strings.Contains(notice, "01a171d5") || strings.Contains(notice, "Request id:") {
|
|
107
|
+
t.Errorf("notice = %q,尾巴应被剥掉(它会直接进卡片的原文行)", notice)
|
|
108
|
+
}
|
|
109
|
+
if body != "" {
|
|
110
|
+
t.Errorf("body = %q,want 空串(这一轮只回了通告)", body)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 402 没有恢复时刻 → 走「整段就是通告」那条窄路。
|
|
114
|
+
notice, body, ok = splitLimitNotice(limitNoticeQuota402)
|
|
115
|
+
if !ok {
|
|
116
|
+
t.Fatalf("中文 402 原文没认出来(没有恢复时刻,靠窄路认)")
|
|
117
|
+
}
|
|
118
|
+
if !strings.HasPrefix(notice, "402 当前已达到") {
|
|
119
|
+
t.Errorf("notice = %q", notice)
|
|
120
|
+
}
|
|
121
|
+
if strings.Contains(notice, "2067") && !strings.Contains(notice, "积分") {
|
|
122
|
+
t.Errorf("notice = %q,尾巴 (2067) 应被剥掉", notice)
|
|
123
|
+
}
|
|
124
|
+
if body != "" {
|
|
125
|
+
t.Errorf("body = %q,want 空串", body)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 402 前面挂着正文 → 不是「整段就是它」,不该整段吞掉(那条窄路只认 body 为空)。
|
|
129
|
+
if _, b, ok := splitLimitNotice("先跑了一遍测试。\n" + limitNoticeQuota402); ok && b != "先跑了一遍测试。" {
|
|
130
|
+
t.Errorf("正文被吞了:body = %q", b)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
84
134
|
func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
|
|
85
135
|
cases := map[string]string{
|
|
86
136
|
"科普句(无限流措辞)": "HTTP 429 是「请求过多」,服务端让你慢一点。",
|
|
@@ -89,6 +139,13 @@ func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
|
|
|
89
139
|
"正文里出现过 429、后面还有别的话": "日志里出现了 429。先看限流配置,额度在 2026-10-11 重置。建议改用另一个模型。",
|
|
90
140
|
"端口号不是状态码": "端口 8080 与 usage 有关。",
|
|
91
141
|
"普通回答": "已按你的要求改完 3 个文件。",
|
|
142
|
+
/* 窄路(整段就是通告、无时刻)要挡住的几类(2026-10-25):
|
|
143
|
+
「429 是限流,把额度用完就等它重置」= 模型在**回答**,措辞全中但没有一个字说
|
|
144
|
+
「你此刻被拒了」。 */
|
|
145
|
+
"释义句(状态码在句首 + 表示)": "429 表示请求过多,额度用完时会自动重置,稍后再试即可。",
|
|
146
|
+
"释义句(中文「是」)": "402 是「需要付费」的意思,余额不足时你先升级套餐。",
|
|
147
|
+
/* 长一点的正常回答里提了额度:整段不短于 160 字 → 不是那条窄路。 */
|
|
148
|
+
"长回答里提额度": strings.Repeat("这一版把额度用完时的提示改成了卡片。", 12),
|
|
92
149
|
}
|
|
93
150
|
for name, in := range cases {
|
|
94
151
|
if _, _, ok := splitLimitNotice(in); ok {
|
|
@@ -97,6 +154,89 @@ func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
|
|
|
97
154
|
}
|
|
98
155
|
}
|
|
99
156
|
|
|
157
|
+
// ── 分类与恢复时刻(2026-10-25,界面额度卡要的三项)────────────────────────
|
|
158
|
+
|
|
159
|
+
func TestClassifyNotice(t *testing.T) {
|
|
160
|
+
cases := []struct{ in, kind, code string }{
|
|
161
|
+
{limitNoticeQuotaEN, ErrorKindQuota, "429"},
|
|
162
|
+
{limitNoticeQuota402, ErrorKindQuota, "402"},
|
|
163
|
+
{limitNoticeZH, ErrorKindRateLimit, "429"}, // 「超出频率限制」→ 短时窗口
|
|
164
|
+
{limitNoticeEN, ErrorKindRateLimit, "429"},
|
|
165
|
+
{"", "", ""},
|
|
166
|
+
{"codebuddy CLI stream ended without result line", "", ""},
|
|
167
|
+
/* 402 即使没有一句措辞也要算额度:它是「需要付费」的语义码(原文可能只有
|
|
168
|
+
「Payment Required」),归到普通错误卡会让人去查配置。 */
|
|
169
|
+
{"402 Payment Required", ErrorKindQuota, "402"},
|
|
170
|
+
}
|
|
171
|
+
for _, c := range cases {
|
|
172
|
+
kind, code := classifyNotice(c.in)
|
|
173
|
+
if kind != c.kind || code != c.code {
|
|
174
|
+
t.Errorf("classifyNotice(%q) = (%q,%q),want (%q,%q)", c.in, kind, code, c.kind, c.code)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func TestParseNoticeReset(t *testing.T) {
|
|
180
|
+
/* 三种拼法都是本仓库日志里的实测原文。**期望值是绝对时刻**,不看本机时区 ——
|
|
181
|
+
带偏移的必须解成同一个瞬间(这就是不用宽松解析的理由:V8 会把 CST 当美国中部时间)。 */
|
|
182
|
+
cases := []struct{ in, want string }{
|
|
183
|
+
{limitNoticeQuotaEN, "2026-10-25T15:07:27+08:00"}, // +0800 无冒号,尾部还有 CST
|
|
184
|
+
{limitNoticeZH, "2026-09-19T17:49:56+08:00"}, // UTC+8 那种拼法
|
|
185
|
+
{limitNoticeEN, "2026-10-11T10:32:59+08:00"}, // UTC+8
|
|
186
|
+
{"will reset at 2026-10-25T07:07:27Z", "2026-10-25T15:07:27+08:00"}, // ISO 形状(Z = UTC)
|
|
187
|
+
{limitNoticeQuota402, ""}, // 没有时刻 → 空(客户端不编「稍后」)
|
|
188
|
+
{"已按你的要求改完 3 个文件。", ""},
|
|
189
|
+
}
|
|
190
|
+
for _, c := range cases {
|
|
191
|
+
got := noticeResetRFC3339(c.in)
|
|
192
|
+
if c.want == "" {
|
|
193
|
+
if got != "" {
|
|
194
|
+
t.Errorf("noticeResetRFC3339(%q) = %q,want 空", c.in, got)
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
wt, err := time.Parse(time.RFC3339, c.want)
|
|
199
|
+
if err != nil {
|
|
200
|
+
t.Fatalf("测试自身的期望值写错了:%v", err)
|
|
201
|
+
}
|
|
202
|
+
gt, err := time.Parse(time.RFC3339, got)
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Errorf("noticeResetRFC3339(%q) = %q(不是 RFC3339)", c.in, got)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if !gt.Equal(wt) {
|
|
208
|
+
t.Errorf("noticeResetRFC3339(%q) = %s,want 同一瞬间 %s", c.in, got, c.want)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 收尾事件要带上三项结构化字段(界面靠它们决定画额度卡还是普通失败卡)。
|
|
214
|
+
func TestLimitNoticeEventCarriesFields(t *testing.T) {
|
|
215
|
+
var got *StreamEvent
|
|
216
|
+
a := &streamAccumulator{}
|
|
217
|
+
a.OnEvent = func(ev StreamEvent) { e := ev; got = &e }
|
|
218
|
+
a.emitLimitNoticeFailed("sid", strings.Join(strings.Fields(stripNoticeTails(limitNoticeQuotaEN)), " "), limitNoticeNarration)
|
|
219
|
+
if got == nil {
|
|
220
|
+
t.Fatal("没发事件")
|
|
221
|
+
}
|
|
222
|
+
if got.ErrorKind != ErrorKindQuota || got.ErrorCode != "429" {
|
|
223
|
+
t.Errorf("kind/code = %q/%q,want quota/429", got.ErrorKind, got.ErrorCode)
|
|
224
|
+
}
|
|
225
|
+
if got.ResetAt != "2026-10-25T15:07:27+08:00" {
|
|
226
|
+
t.Errorf("ResetAt = %q,want 2026-10-25T15:07:27+08:00", got.ResetAt)
|
|
227
|
+
}
|
|
228
|
+
if got.Text != limitNoticeNarration {
|
|
229
|
+
t.Errorf("Text = %q,want 剥掉通告的正文", got.Text)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 402 那条:有 kind/code、**没有** reset(客户端据此不写「将于…恢复」)。
|
|
233
|
+
got = nil
|
|
234
|
+
a.emitLimitNoticeFailed("sid", strings.Join(strings.Fields(stripNoticeTails(limitNoticeQuota402)), " "), "")
|
|
235
|
+
if got == nil || got.ErrorKind != ErrorKindQuota || got.ErrorCode != "402" || got.ResetAt != "" {
|
|
236
|
+
t.Errorf("402 那条的 kind/code/reset = %+v", got)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
100
240
|
// codebuddyStreamWithResult 造一个假 codebuddy CLI:只吐一条给定正文的 result 行。
|
|
101
241
|
// 正文里带单引号(英文通告的 don't)时没法直接写进 shell 脚本,所以落成文件再 cat。
|
|
102
242
|
func codebuddyStreamWithResult(t *testing.T, text string) string {
|
package/internal/agent/stream.go
CHANGED
|
@@ -258,6 +258,25 @@ type StreamEvent struct {
|
|
|
258
258
|
//(同走 agent.ReasonOf),同一次失败在事件流与 stderr 两处的说法必然一致。
|
|
259
259
|
Reason string
|
|
260
260
|
|
|
261
|
+
/* ── 失败分类(2026-10-25)──
|
|
262
|
+
*
|
|
263
|
+
* 为什么要有这三个字段:客户端要画一张**额度卡**(什么时候恢复、该怎么办),
|
|
264
|
+
* 而 Error / Reason 只有一段人话原文。让每个客户端各自从原文里正则反推,
|
|
265
|
+
* 就是三端三套判据、各错各的;分类口径收在 core(见 limitnotice.go),
|
|
266
|
+
* 客户端只做展示。
|
|
267
|
+
*
|
|
268
|
+
* 空串 = 与以前完全一致(非 turn_failed 的事件、以及认不出类别的失败)。
|
|
269
|
+
* 按 CLIENT-CONTRACT 的「只增字段不升 contractVersion」规则添加,
|
|
270
|
+
* 老消费方忽略它们即可。 */
|
|
271
|
+
|
|
272
|
+
// ErrorKind 失败类别:quota(额度用尽)/ rate_limit(频率限制)/ ""(未分类)。
|
|
273
|
+
ErrorKind string
|
|
274
|
+
// ErrorCode 通告里的状态码(429 / 402 / 401 / 403 / 5xx),空 = 原文里没有。
|
|
275
|
+
ErrorCode string
|
|
276
|
+
// ResetAt 供应商给的恢复时刻(RFC3339)。空 = 通告里没说什么时候恢复 ——
|
|
277
|
+
// 客户端此时**不要**编「稍后」,把这一行整条省掉。
|
|
278
|
+
ResetAt string
|
|
279
|
+
|
|
261
280
|
/* Parent 父委派 id(2026-10-21,团队模式)。
|
|
262
281
|
*
|
|
263
282
|
* 语义与 claude 系原生的 `parent_tool_use_id` 完全一致:非空表示「这条事件发生在
|
|
@@ -1729,6 +1748,10 @@ func (a *streamAccumulator) emitTurnFailed(sessionID string, err error) {
|
|
|
1729
1748
|
Reason: ReasonOf(err),
|
|
1730
1749
|
Status: StatusFailed,
|
|
1731
1750
|
}
|
|
1751
|
+
/* 「协议没跑完」的错误串里也可能夹着供应商的 429 原文(runner 的重试判据就是
|
|
1752
|
+
靠这种子串认出来的),所以这里同样分一次类 —— 认出来客户端才画得出额度卡。 */
|
|
1753
|
+
ev.ErrorKind, ev.ErrorCode = classifyNotice(ev.Reason + "\n" + ev.Error)
|
|
1754
|
+
ev.ResetAt = noticeResetRFC3339(ev.Reason + "\n" + ev.Error)
|
|
1732
1755
|
// 已建立 item 时把失败也挂到同一个 item 上:partial 的内容仍然存在,
|
|
1733
1756
|
// 只是这条 item 不会再更新了(Status=failed 明确终止增长)。
|
|
1734
1757
|
if attrs, ok := a.trackers().failText(a.joinBlocks(a.textBlocks)); ok {
|
|
@@ -1762,6 +1785,13 @@ func (a *streamAccumulator) emitLimitNoticeFailed(sessionID, notice, body string
|
|
|
1762
1785
|
Reason: notice,
|
|
1763
1786
|
Status: StatusFailed,
|
|
1764
1787
|
}
|
|
1788
|
+
/* 分类与恢复时刻就在这里定(客户端只展示,不自己解析原文)——
|
|
1789
|
+
这条路径**已经认定**是限流/额度通告,所以 kind 至少是 rate_limit。 */
|
|
1790
|
+
ev.ErrorKind, ev.ErrorCode = classifyNotice(notice)
|
|
1791
|
+
if ev.ErrorKind == "" {
|
|
1792
|
+
ev.ErrorKind = ErrorKindRateLimit
|
|
1793
|
+
}
|
|
1794
|
+
ev.ResetAt = noticeResetRFC3339(notice)
|
|
1765
1795
|
/* 快照 = 剥掉通告的正文;正文为空(这一轮引擎只回了通告)时退回**累积正文** ——
|
|
1766
1796
|
引擎把通告混在最后一条 assistant 消息里时,那里面也夹着它,再判一次剥掉。
|
|
1767
1797
|
(累积正文里判不出来就原样留着:宁可多留一句话,也不把用户真看过的正文抹成空。) */
|
|
@@ -65,7 +65,17 @@ func runStreamJSONIn(
|
|
|
65
65
|
⚠️ 抛错会让 lineHandler 中止读循环,那里会顺手 kill 掉引擎进程组
|
|
66
66
|
(见 runStreamCLIStdinReaderIn),所以这里不能改成「先把事件转发出去、
|
|
67
67
|
等收尾再抛」—— 那样 turn_failed 会晚到,消费方早就按 turn_end 收尾了。 */
|
|
68
|
-
|
|
68
|
+
/* ⚠️ **判的是「屏幕上真正显示的那段」,不是 result 字段**(2026-10-25):
|
|
69
|
+
AggregateOnly 引擎(codebuddy 实测)不发 content_block_delta,正文只随
|
|
70
|
+
assistant 聚合行以 text 增量下来,而收尾 result 行的 result **是空串** ——
|
|
71
|
+
只判 fin.Result 的话,额度通告永远认不出来,界面上就是「一整段 429 英文 +
|
|
72
|
+
已完成」(用户 2026-10-25 截图报障:「没额度了」)。
|
|
73
|
+
口径与 emitTurnEnd 的落笔一致:result 非空以它为准,空则退回累积正文。 */
|
|
74
|
+
shown := fin.Result
|
|
75
|
+
if strings.TrimSpace(shown) == "" {
|
|
76
|
+
shown = acc.joinBlocks(acc.textBlocks)
|
|
77
|
+
}
|
|
78
|
+
if notice, body, hit := splitLimitNotice(shown); hit {
|
|
69
79
|
acc.emitLimitNoticeFailed(fin.SessionID, notice, body)
|
|
70
80
|
return errors.New(notice)
|
|
71
81
|
}
|
package/internal/cli/ask.go
CHANGED
|
@@ -147,6 +147,10 @@ type askOptions struct {
|
|
|
147
147
|
suggestFallbackEngine string // --suggest-fallback-engine
|
|
148
148
|
suggestFallbackModel string // --suggest-fallback-model
|
|
149
149
|
|
|
150
|
+
// 自动任务模式的冲突判定(2026-10-26):一次性旁路调用,不续接会话。
|
|
151
|
+
// 值 = 请求 JSON 的文件路径("-" = stdin)。
|
|
152
|
+
judgeInput string // --judge-conflict <file|->:{"prompt":"…","candidates":[…]}
|
|
153
|
+
|
|
150
154
|
// 四档权限模型(见 internal/agent/permission.go)。仅 claude / codebuddy 接线。
|
|
151
155
|
permission string // 档位:manual | accept-edits | auto | full
|
|
152
156
|
sandboxExclude []string // 始终在沙箱外执行的命令(sandbox.excludedCommands)
|
|
@@ -244,6 +248,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
|
|
|
244
248
|
f.StringVar(&opts.suggestInput, "suggest-followups", "", "生成下一轮快捷回复:传请求 JSON 的文件路径(\"-\" = stdin):{\"user\":\"…\",\"reply\":\"…\"}。旁路调用,不续接当前会话;引擎走 -e(默认 llm)")
|
|
245
249
|
f.StringVar(&opts.suggestFallbackEngine, "suggest-fallback-engine", "", "配合 --suggest-followups:建议引擎的备用(主建议引擎失败时先试它,再落到内置 llm 兜底)")
|
|
246
250
|
f.StringVar(&opts.suggestFallbackModel, "suggest-fallback-model", "", "配合 --suggest-followups:建议备用引擎的模型(空 = 该引擎默认)")
|
|
251
|
+
f.StringVar(&opts.judgeInput, "judge-conflict", "", "判断本轮任务与同工作空间运行中任务是否冲突(自动任务模式用):传请求 JSON 的文件路径(\"-\" = stdin):{\"prompt\":\"…\",\"candidates\":[{title,recent,files}]}。旁路调用,不续接会话;引擎走 -e(默认 llm)")
|
|
247
252
|
f.Uint64Var(&opts.logAfter, "after", 0, "配合 --session-log:增量续读,只回 seq > N 的事件(另附 count/after/lastSeq/snapshotRequired)")
|
|
248
253
|
f.Uint64Var(&opts.logBefore, "before", 0, "配合 --session-log:向上翻更早的历史,从文件尾跳过 N 条已画事件再取一页(0 = 最新一页;下一页把返回的 nextBefore 传回来。另附 count/before/nextBefore/firstSeq/more;与 --after 互斥)")
|
|
249
254
|
f.IntVar(&opts.logLimit, "limit", 200, "配合 --before:每页最多返回多少条事件(默认 200)")
|
|
@@ -798,6 +803,9 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
798
803
|
Status: string(ev.Status), Parent: ev.Parent,
|
|
799
804
|
SubSessionID: ev.SubSessionID,
|
|
800
805
|
Error: ev.Error, Reason: ev.Reason,
|
|
806
|
+
/* 失败分类(2026-10-25):落盘它,历史回放才画得出**同一张**额度卡 ——
|
|
807
|
+
只在实时流里带,用户翻历史时那一轮会退回普通失败卡(两张脸)。 */
|
|
808
|
+
ErrorKind: ev.ErrorKind, ErrorCode: ev.ErrorCode, ResetAt: ev.ResetAt,
|
|
801
809
|
})
|
|
802
810
|
}
|
|
803
811
|
if format == agent.FormatJSON {
|
|
@@ -30,6 +30,7 @@ import (
|
|
|
30
30
|
"context"
|
|
31
31
|
"encoding/json"
|
|
32
32
|
"errors"
|
|
33
|
+
"fmt"
|
|
33
34
|
"strings"
|
|
34
35
|
"testing"
|
|
35
36
|
|
|
@@ -203,3 +204,105 @@ func TestEnginesInstallKeyOmittedWhenNoCommand(t *testing.T) {
|
|
|
203
204
|
}
|
|
204
205
|
t.Fatal("--engines 输出里没找到 install-fake-ok-2")
|
|
205
206
|
}
|
|
207
|
+
|
|
208
|
+
// TestEnginesModelsSourceDistinguishesNoSourceFromFailure 钉住 2026-10 报障的根因:
|
|
209
|
+
// 「引擎没有模型源」(arkclaw 按 claw_id 绑模型 / codebuddy-gateway 用网关进程的模型)
|
|
210
|
+
// 与「模型探测失败」(openclaw 因 Node 版本门禁 exit 1)是两种语义,客户端不能都隐藏。
|
|
211
|
+
//
|
|
212
|
+
// 约定:
|
|
213
|
+
//
|
|
214
|
+
// models_source = none → 引擎本身不提供模型选择,**保留**在可选清单里;
|
|
215
|
+
// = failed → 有来源但探测失败,可以隐藏 / 置灰;
|
|
216
|
+
// = dynamic→ models 可用。
|
|
217
|
+
func TestEnginesModelsSourceDistinguishesNoSourceFromFailure(t *testing.T) {
|
|
218
|
+
clearModelListCache()
|
|
219
|
+
t.Cleanup(clearModelListCache)
|
|
220
|
+
|
|
221
|
+
registerFake(&modelListEngine{
|
|
222
|
+
stringEngine: stringEngine{name: "__no_source__", text: "x"},
|
|
223
|
+
err: fmt.Errorf("%w: bound by claw_id", agent.ErrNoModelSource),
|
|
224
|
+
})
|
|
225
|
+
registerFake(&modelListEngine{
|
|
226
|
+
stringEngine: stringEngine{name: "__probe_failed__", text: "x"},
|
|
227
|
+
err: errors.New("exit status 1"),
|
|
228
|
+
})
|
|
229
|
+
registerFake(&modelListEngine{
|
|
230
|
+
stringEngine: stringEngine{name: "__dynamic__", text: "x"},
|
|
231
|
+
models: []string{"m1", "m2"},
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
stdout, _, err := runAskCmd(t, "", "--engines")
|
|
235
|
+
if err != nil {
|
|
236
|
+
t.Fatalf("--engines: %v", err)
|
|
237
|
+
}
|
|
238
|
+
var rows []struct {
|
|
239
|
+
Engine string `json:"engine"`
|
|
240
|
+
Models []string `json:"models"`
|
|
241
|
+
ModelsNote string `json:"models_note"`
|
|
242
|
+
ModelsSource string `json:"models_source"`
|
|
243
|
+
}
|
|
244
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
245
|
+
t.Fatalf("--engines 输出不是 JSON 数组: %v\n%s", err, stdout)
|
|
246
|
+
}
|
|
247
|
+
byName := map[string]int{}
|
|
248
|
+
for i, r := range rows {
|
|
249
|
+
byName[r.Engine] = i
|
|
250
|
+
}
|
|
251
|
+
get := func(name string) (int, struct {
|
|
252
|
+
Engine string `json:"engine"`
|
|
253
|
+
Models []string `json:"models"`
|
|
254
|
+
ModelsNote string `json:"models_note"`
|
|
255
|
+
ModelsSource string `json:"models_source"`
|
|
256
|
+
}) {
|
|
257
|
+
t.Helper()
|
|
258
|
+
i, ok := byName[name]
|
|
259
|
+
if !ok {
|
|
260
|
+
t.Fatalf("--engines 输出里没找到 %s", name)
|
|
261
|
+
}
|
|
262
|
+
return i, rows[i]
|
|
263
|
+
}
|
|
264
|
+
_, noSource := get("__no_source__")
|
|
265
|
+
if noSource.ModelsSource != "none" {
|
|
266
|
+
t.Errorf("无模型源引擎 models_source = %q want none", noSource.ModelsSource)
|
|
267
|
+
}
|
|
268
|
+
if len(noSource.Models) != 0 {
|
|
269
|
+
t.Errorf("无模型源引擎不该有 models: %v", noSource.Models)
|
|
270
|
+
}
|
|
271
|
+
if !strings.Contains(noSource.ModelsNote, "no dynamic model source") {
|
|
272
|
+
t.Errorf("无模型源引擎仍应保留人读原因,got %q", noSource.ModelsNote)
|
|
273
|
+
}
|
|
274
|
+
_, failed := get("__probe_failed__")
|
|
275
|
+
if failed.ModelsSource != "failed" {
|
|
276
|
+
t.Errorf("探测失败引擎 models_source = %q want failed", failed.ModelsSource)
|
|
277
|
+
}
|
|
278
|
+
_, dynamic := get("__dynamic__")
|
|
279
|
+
if dynamic.ModelsSource != "dynamic" {
|
|
280
|
+
t.Errorf("正常引擎 models_source = %q want dynamic", dynamic.ModelsSource)
|
|
281
|
+
}
|
|
282
|
+
if len(dynamic.Models) != 2 {
|
|
283
|
+
t.Errorf("正常引擎 models = %v want 2 条", dynamic.Models)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// TestEnginesModelsSourceOmittedWithoutLister 钉住「没有 ModelLister 的引擎」也标 none:
|
|
288
|
+
// 它们连探测入口都没有,属于「本身不提供」,不能报成 failed。
|
|
289
|
+
func TestEnginesModelsSourceOmittedWithoutLister(t *testing.T) {
|
|
290
|
+
registerFake(&stringEngine{name: "no-lister-fake", text: "x"})
|
|
291
|
+
stdout, _, err := runAskCmd(t, "", "--engines")
|
|
292
|
+
if err != nil {
|
|
293
|
+
t.Fatalf("--engines: %v", err)
|
|
294
|
+
}
|
|
295
|
+
var rows []map[string]any
|
|
296
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
297
|
+
t.Fatalf("解析失败: %v\n%s", err, stdout)
|
|
298
|
+
}
|
|
299
|
+
for _, r := range rows {
|
|
300
|
+
if r["engine"] == "no-lister-fake" {
|
|
301
|
+
if got := r["models_source"]; got != "none" {
|
|
302
|
+
t.Errorf("无 ModelLister 引擎 models_source = %v want none", got)
|
|
303
|
+
}
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
t.Fatal("--engines 输出里没找到 no-lister-fake")
|
|
308
|
+
}
|
package/internal/cli/events.go
CHANGED
|
@@ -222,6 +222,14 @@ func streamEventPayload(ev agent.StreamEvent) any {
|
|
|
222
222
|
与 -o json 的失败 envelope 同源(都走 agent.ReasonOf)。 */
|
|
223
223
|
Error string `json:"error,omitempty"`
|
|
224
224
|
Reason string `json:"reason,omitempty"`
|
|
225
|
+
/* 失败分类(2026-10-25,仅 turn_failed):客户端据此决定画哪种卡 ——
|
|
226
|
+
error_kind=quota 画「额度用尽」卡(等恢复 / 换模型),rate_limit 画
|
|
227
|
+
「请求太频繁」卡,其它或空就退回普通「调用失败」卡。
|
|
228
|
+
error_code 是通告里的状态码(429 / 402 …),reset_at 是供应商给的恢复
|
|
229
|
+
时刻(RFC3339)。全都 omitempty:分不出来时字段不出现,老消费者字段集不变。 */
|
|
230
|
+
ErrorKind string `json:"error_kind,omitempty"`
|
|
231
|
+
ErrorCode string `json:"error_code,omitempty"`
|
|
232
|
+
ResetAt string `json:"reset_at,omitempty"`
|
|
225
233
|
}{
|
|
226
234
|
Type: string(ev.Kind),
|
|
227
235
|
Text: ev.Text,
|
|
@@ -239,6 +247,9 @@ func streamEventPayload(ev agent.StreamEvent) any {
|
|
|
239
247
|
Parent: ev.Parent,
|
|
240
248
|
Error: ev.Error,
|
|
241
249
|
Reason: ev.Reason,
|
|
250
|
+
ErrorKind: ev.ErrorKind,
|
|
251
|
+
ErrorCode: ev.ErrorCode,
|
|
252
|
+
ResetAt: ev.ResetAt,
|
|
242
253
|
}
|
|
243
254
|
}
|
|
244
255
|
|