magic-agent 2.4.3 → 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.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -25,12 +25,17 @@ import (
25
25
  * {"op":"answer","text":"..."} → 把答案作为后续 user 消息追进常驻会话
26
26
  * → {"type":"answered"};通道不可用 →
27
27
  * {"type":"control_error","error":"..."}
28
+ * {"op":"ask_answer","request_id":"perm_…","allowed":true|false,"message":"…","tool_name":"…"}
29
+ * → **原地**回应引擎的 control_request(计划审批 /
30
+ * 工具授权),走子进程 stdin 写一行 control_response
31
+ * → {"type":"ask_answered",…};无落点(引擎不支持 /
32
+ * 本轮已结束 / stdin 已关)→ control_error
28
33
  * 其它 → {"type":"control_error","op":"..."}
29
34
  *
30
- * ⚠️ answer 为什么走「后续 user 消息」而不是原地作答:headless 下 claude 在模型提问后
31
- * 会**立刻自行拒绝**(见 agent.KindAsk 的注释),唯一能把答案真正喂给模型的通道就是
32
- * 下一轮 user 消息 —— 也就是常驻会话的追加通道,与 `--append` 是同一条路。
33
- * 因此 answer 只在常驻会话生效时可用;不可用时明确回一条 control_error,不静默丢弃。
35
+ * ⚠️ answer 与 ask_answer 是**两件事**,别合并:
36
+ * · answer 是「下一轮用户消息」(headless 下模型提问后 CLI 会自行拒绝,答案只能这样补);
37
+ * · ask_answer 是「这一轮正阻塞等着的裁决」(ExitPlanMode 计划审批、manual 档的工具授权),
38
+ * 必须写回 CLI 的 control_response —— 当 user 消息发进去它不会读,整轮继续挂着。
34
39
  */
35
40
 
36
41
  // controlCommand 一条控制命令。
@@ -38,6 +43,14 @@ type controlCommand struct {
38
43
  Op string `json:"op"`
39
44
  Reason string `json:"reason,omitempty"`
40
45
  Text string `json:"text,omitempty"`
46
+ // RequestID 引擎 control_request 的 id(ask_answer 必填;原样带回 control_response)。
47
+ RequestID string `json:"request_id,omitempty"`
48
+ // Allowed 裁决结果(ask_answer 必填;nil = 没给 → 明确报错,不猜)。
49
+ Allowed *bool `json:"allowed,omitempty"`
50
+ // Message 拒绝理由 / 想回给引擎的话。
51
+ Message string `json:"message,omitempty"`
52
+ // ToolName 待裁决的工具名(只用于回执与排障,编码时不参与)。
53
+ ToolName string `json:"tool_name,omitempty"`
41
54
  }
42
55
 
43
56
  // controlHooks 控制命令的落地动作。
@@ -48,6 +61,9 @@ type controlHooks struct {
48
61
  stop func()
49
62
  // answer 把答案追进常驻会话;返回 false 表示通道此刻不可用。
50
63
  answer func(text string) bool
64
+ // answerAsk 原地回应一条 control_request;返回 false 表示写不回去
65
+ //(引擎不支持 / stdin 已关 / 这一轮已结束),调用方据此回 control_error。
66
+ answerAsk func(requestID string, allowed bool, message, toolName string) bool
51
67
  }
52
68
 
53
69
  // controlSession 一次控制通道的生命周期。
@@ -129,10 +145,35 @@ func (s *controlSession) dispatch(c controlCommand) {
129
145
  }
130
146
  _ = s.sink.notice("answered", nil)
131
147
 
148
+ case "ask_answer":
149
+ if strings.TrimSpace(c.RequestID) == "" {
150
+ _ = s.sink.notice("control_error", map[string]any{
151
+ "op": "ask_answer", "error": "ask_answer 需要 request_id(引擎 control_request 的 id)",
152
+ })
153
+ return
154
+ }
155
+ if c.Allowed == nil {
156
+ _ = s.sink.notice("control_error", map[string]any{
157
+ "op": "ask_answer", "error": "ask_answer 需要 allowed(true = 允许,false = 拒绝)",
158
+ })
159
+ return
160
+ }
161
+ if s.hooks.answerAsk == nil || !s.hooks.answerAsk(c.RequestID, *c.Allowed, c.Message, c.ToolName) {
162
+ _ = s.sink.notice("control_error", map[string]any{
163
+ "op": "ask_answer",
164
+ "error": "这一轮没法原地回审批:引擎不支持控制应答,或 stdin 已关(本轮已结束)。" +
165
+ "请改用 answer(会把答复作为下一轮用户消息发进会话)",
166
+ })
167
+ return
168
+ }
169
+ _ = s.sink.notice("ask_answered", map[string]any{
170
+ "request_id": c.RequestID, "allowed": *c.Allowed, "tool_name": c.ToolName,
171
+ })
172
+
132
173
  default:
133
174
  _ = s.sink.notice("control_error", map[string]any{
134
175
  "op": c.Op,
135
- "error": "未知 op:支持 ping / interrupt / stop / answer",
176
+ "error": "未知 op:支持 ping / interrupt / stop / answer / ask_answer",
136
177
  })
137
178
  }
138
179
  }
@@ -129,6 +129,71 @@ func TestControlAnswer(t *testing.T) {
129
129
  })
130
130
  }
131
131
 
132
+ // TestControlAskAnswer 原地回审批(ask_answer):参数校验、成败回执,以及
133
+ // 它和 answer(下一轮用户消息)是两条不同的路 —— 别把审批偷偷降级成追加。
134
+ func TestControlAskAnswer(t *testing.T) {
135
+ t.Run("缺 request_id", func(t *testing.T) {
136
+ w := newLineWriter()
137
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","allowed":true}`+"\n"),
138
+ newEventSink(w, "codebuddy", true), controlHooks{})
139
+ defer ctl.Close()
140
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") || !strings.Contains(ln, "request_id") {
141
+ t.Errorf("应回带 request_id 的 control_error,实际 %s", ln)
142
+ }
143
+ })
144
+
145
+ t.Run("缺 allowed", func(t *testing.T) {
146
+ w := newLineWriter()
147
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","request_id":"perm_1"}`+"\n"),
148
+ newEventSink(w, "codebuddy", true), controlHooks{})
149
+ defer ctl.Close()
150
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") || !strings.Contains(ln, "allowed") {
151
+ t.Errorf("应回带 allowed 的 control_error,实际 %s", ln)
152
+ }
153
+ })
154
+
155
+ t.Run("写不回去", func(t *testing.T) {
156
+ w := newLineWriter()
157
+ ctl := startControlReader(strings.NewReader(`{"op":"ask_answer","request_id":"perm_1","allowed":true}`+"\n"),
158
+ newEventSink(w, "codebuddy", true), controlHooks{})
159
+ defer ctl.Close()
160
+ if ln := w.wait(t, 1)[0]; !strings.Contains(ln, "control_error") {
161
+ t.Errorf("没有 answerAsk 时应回 control_error,实际 %s", ln)
162
+ }
163
+ })
164
+
165
+ t.Run("可用", func(t *testing.T) {
166
+ w := newLineWriter()
167
+ var gotReq atomic.Value
168
+ var gotAllowed atomic.Bool
169
+ var gotMsg atomic.Value
170
+ ctl := startControlReader(strings.NewReader(
171
+ `{"op":"ask_answer","request_id":"perm_9","allowed":false,"message":"先改改","tool_name":"ExitPlanMode"}`+"\n"),
172
+ newEventSink(w, "codebuddy", true), controlHooks{
173
+ answerAsk: func(requestID string, allowed bool, message, toolName string) bool {
174
+ gotReq.Store(requestID + "|" + toolName)
175
+ gotAllowed.Store(allowed)
176
+ gotMsg.Store(message)
177
+ return true
178
+ },
179
+ })
180
+ defer ctl.Close()
181
+ ln := w.wait(t, 1)[0]
182
+ if !strings.Contains(ln, `"type":"ask_answered"`) || !strings.Contains(ln, `"request_id":"perm_9"`) {
183
+ t.Errorf("应回 ask_answered 并带回 request_id,实际 %s", ln)
184
+ }
185
+ if v, _ := gotReq.Load().(string); v != "perm_9|ExitPlanMode" {
186
+ t.Errorf("hook 收到的 request_id/tool_name = %q", v)
187
+ }
188
+ if gotAllowed.Load() {
189
+ t.Error("allowed 应为 false")
190
+ }
191
+ if v, _ := gotMsg.Load().(string); v != "先改改" {
192
+ t.Errorf("message = %q want 先改改", v)
193
+ }
194
+ })
195
+ }
196
+
132
197
  // TestControlInterruptAndStop interrupt 不立即回应答(应答在本轮真的结束时发),
133
198
  // stop 立即回;两个标志位都要落。
134
199
  func TestControlInterruptAndStop(t *testing.T) {
@@ -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
+ }
@@ -202,6 +202,9 @@ func streamEventPayload(ev agent.StreamEvent) any {
202
202
  session_id)。omitempty:非团队事件不带它,老消费者字段集不变。 */
203
203
  SubSessionID string `json:"sub_session_id,omitempty"`
204
204
  Ask *agent.AskRequest `json:"ask,omitempty"`
205
+ /* 主备降级(2026-10-26,仅 type=fallback):from/to 两端 + 是否换了引擎 + 原因。
206
+ omitempty:其它事件不带它,老消费者字段集不变。 */
207
+ Fallback *agent.FallbackEvent `json:"fallback,omitempty"`
205
208
  /* 状态收敛四元组(2026-10-02,对齐 agents-anywhere 的 timeline item)。
206
209
  消费方按 (item_id, item_revision) 覆盖式 upsert 即可:
207
210
  同一 item 的多个 partial 帧 + 最后一个 final 帧,
@@ -219,6 +222,14 @@ func streamEventPayload(ev agent.StreamEvent) any {
219
222
  与 -o json 的失败 envelope 同源(都走 agent.ReasonOf)。 */
220
223
  Error string `json:"error,omitempty"`
221
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"`
222
233
  }{
223
234
  Type: string(ev.Kind),
224
235
  Text: ev.Text,
@@ -228,6 +239,7 @@ func streamEventPayload(ev agent.StreamEvent) any {
228
239
  SessionID: ev.SessionID,
229
240
  SubSessionID: ev.SubSessionID,
230
241
  Ask: ev.Ask,
242
+ Fallback: ev.Fallback,
231
243
  ItemID: ev.ItemID,
232
244
  ItemRevision: ev.ItemRevision,
233
245
  Status: string(ev.Status),
@@ -235,6 +247,9 @@ func streamEventPayload(ev agent.StreamEvent) any {
235
247
  Parent: ev.Parent,
236
248
  Error: ev.Error,
237
249
  Reason: ev.Reason,
250
+ ErrorKind: ev.ErrorKind,
251
+ ErrorCode: ev.ErrorCode,
252
+ ResetAt: ev.ResetAt,
238
253
  }
239
254
  }
240
255
 
@@ -31,6 +31,15 @@ func sinkSamples() []agent.StreamEvent {
31
31
  Reason: "without result line",
32
32
  Status: agent.StatusFailed, ItemID: "claude_msg_1", ItemRevision: 4,
33
33
  },
34
+ /* 额度卡三项(2026-10-25):非空时才出现在 wire 上 —— 放进样本是为了让上面
35
+ 那条「两条路径字段集只差 v/seq」的断言也覆盖到它们(否则新字段漏了 marshal
36
+ 到老形状里也测不出来)。 */
37
+ {
38
+ Kind: agent.KindTurnFailed, Text: "", SessionID: "sess_quota",
39
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
40
+ ErrorKind: agent.ErrorKindQuota, ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
41
+ Status: agent.StatusFailed,
42
+ },
34
43
  }
35
44
  }
36
45
 
@@ -250,6 +259,47 @@ func TestTurnFailedPayloadFields(t *testing.T) {
250
259
  }
251
260
  }
252
261
 
262
+ // 额度卡三项必须原样过 wire(客户端靠它决定画额度卡还是普通失败卡):
263
+ // 名字与 CLAUDE-CONTRACT / CLIENT-CONTRACT.md 1.1.1 那张表逐字一致,改一个字母
264
+ // 客户端就再也认不出来 —— 所以这里按**字符串字段名**断言,而不是读结构体。
265
+ func TestTurnFailedQuotaFields(t *testing.T) {
266
+ var buf bytes.Buffer
267
+ s := newEventSink(&buf, "codebuddy", true)
268
+ if err := s.stream(agent.StreamEvent{
269
+ Kind: agent.KindTurnFailed, SessionID: "s2",
270
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
271
+ ErrorKind: agent.ErrorKindQuota, ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
272
+ Status: agent.StatusFailed,
273
+ }); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+ m := decodeEventLine(t, buf.String())
277
+ if m["error_kind"] != "quota" {
278
+ t.Errorf("error_kind = %v,want quota", m["error_kind"])
279
+ }
280
+ if m["error_code"] != "429" {
281
+ t.Errorf("error_code = %v,want 429", m["error_code"])
282
+ }
283
+ if m["reset_at"] != "2026-10-25T15:07:27+08:00" {
284
+ t.Errorf("reset_at = %v", m["reset_at"])
285
+ }
286
+
287
+ /* 分不出来时三项都不出现(omitempty)—— 老消费者的字段集因此不变。 */
288
+ buf.Reset()
289
+ if err := s.stream(agent.StreamEvent{
290
+ Kind: agent.KindTurnFailed, SessionID: "s3",
291
+ Error: "工具执行失败", Reason: "工具执行失败", Status: agent.StatusFailed,
292
+ }); err != nil {
293
+ t.Fatal(err)
294
+ }
295
+ m = decodeEventLine(t, buf.String())
296
+ for _, k := range []string{"error_kind", "error_code", "reset_at"} {
297
+ if _, ok := m[k]; ok {
298
+ t.Errorf("分不出来时不该出现 %s:%v", k, m)
299
+ }
300
+ }
301
+ }
302
+
253
303
  // ── 黄金样本:钉住 Claude / CodeBuddy 在 wire 上的真实字节形状 ──
254
304
  //
255
305
  // 为什么不复用上面的 TestStreamEventPayloadConvergenceFields:
@@ -0,0 +1,104 @@
1
+ package cli
2
+
3
+ // judge.go - `--judge-conflict <file|->`:判断本轮任务与同空间运行中任务是否冲突。
4
+ //
5
+ // 为什么要单独一条 CLI(2026-10-26):「自动」任务模式要在**开跑前**决定这一轮是并行还是
6
+ // 排队,判断依据是两边「在干什么」—— 这是语义问题,只能由模型答;而提示词与解析口径必须
7
+ // 只有一份(移动端 / PC / 服务端都不能各写一套)。口径收进 core(internal/judge)之后,
8
+ // magic-client 的作业队列 exec 这一条即可。
9
+ //
10
+ // 与 `--suggest-followups` 同一套旁路调用:独立进程、新会话、ToolsOff、不写历史。
11
+ //
12
+ // 退出码:
13
+ //
14
+ // 0 判定完成(conflict=true / false 都是成功;调用方按结论排队或放行)
15
+ // 1 引擎失败(不可用 / 超时 / 输出不是 JSON)—— 调用方按「默认并行」放行
16
+ // 2 参数错(请求 JSON 读不到 / 不合法 / 缺 prompt)
17
+
18
+ import (
19
+ "encoding/json"
20
+ "fmt"
21
+ "io"
22
+ "os"
23
+ "strings"
24
+
25
+ "github.com/spf13/cobra"
26
+
27
+ "github.com/darren/magic-agent/internal/judge"
28
+ )
29
+
30
+ // runJudgeConflict 读请求 JSON → 调 judge.Judge → 打印 JSON 结论。
31
+ func runJudgeConflict(cmd *cobra.Command, opts *askOptions) error {
32
+ body, err := readJudgeInput(cmd, opts.judgeInput)
33
+ if err != nil {
34
+ return &usageError{err}
35
+ }
36
+ /* 引擎:没显式传 -e 时用 llm(启动最快,2.5s 预算内能答完);传了就以调用方的为准
37
+ (调用方传的是用户配的「建议引擎」)。 */
38
+ engine := judge.DefaultEngine
39
+ if flagChanged(cmd, "engine") {
40
+ engine = opts.engine
41
+ }
42
+ model := ""
43
+ if flagChanged(cmd, "model") {
44
+ model = opts.model
45
+ }
46
+ res, err := judge.Judge(cmd.Context(), judge.Request{
47
+ Prompt: body.Prompt,
48
+ Candidates: body.Candidates,
49
+ Engine: engine,
50
+ Model: model,
51
+ Timeout: judge.DefaultTimeout,
52
+ })
53
+ if err != nil {
54
+ return fmt.Errorf("--judge-conflict: 判定失败: %w", err)
55
+ }
56
+ if res.Overlaps == nil {
57
+ res.Overlaps = []string{}
58
+ }
59
+ return printJSON(cmd.OutOrStdout(), res)
60
+ }
61
+
62
+ // readJudgeInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file`)。
63
+ // 上限 256KB:判定只该带「本轮提示词 + 几个候选的摘要」,超了说明调用方塞了整条历史。
64
+ func readJudgeInput(cmd *cobra.Command, spec string) (judge.RequestBody, error) {
65
+ var body judge.RequestBody
66
+ src := strings.TrimSpace(spec)
67
+ read := func(r io.Reader, from string) error {
68
+ b, err := io.ReadAll(io.LimitReader(r, 256<<10))
69
+ if err != nil {
70
+ return fmt.Errorf("读%s失败:%w", from, err)
71
+ }
72
+ if err := json.Unmarshal(b, &body); err != nil {
73
+ return fmt.Errorf("%s不是合法 JSON:%w", from, err)
74
+ }
75
+ return nil
76
+ }
77
+ switch src {
78
+ case "":
79
+ return body, fmt.Errorf("--judge-conflict 需要请求 JSON:传文件路径或 \"-\"(stdin)")
80
+ case "-":
81
+ if err := read(cmd.InOrStdin(), "stdin "); err != nil {
82
+ return body, err
83
+ }
84
+ default:
85
+ path := strings.TrimPrefix(src, "@")
86
+ if strings.HasPrefix(path, "~/") {
87
+ if home, herr := os.UserHomeDir(); herr == nil {
88
+ path = home + path[1:]
89
+ }
90
+ }
91
+ f, err := os.Open(path)
92
+ if err != nil {
93
+ return body, fmt.Errorf("读请求文件失败:%w", err)
94
+ }
95
+ defer f.Close()
96
+ if err := read(f, "请求文件 "); err != nil {
97
+ return body, err
98
+ }
99
+ }
100
+ if strings.TrimSpace(body.Prompt) == "" {
101
+ return body, fmt.Errorf(`缺少 prompt(本轮任务):形如 {"prompt":"…","candidates":[…]}(candidates 为空表示没有冲突对象)`)
102
+ }
103
+ return body, nil
104
+ }
@@ -0,0 +1,125 @@
1
+ package cli
2
+
3
+ // quotas.go - `--quotas`:只查「账号额度 / 模型套餐额度 / 每日签到」的轻量命令。
4
+ //
5
+ // 为什么单独一条:`--engines` 为了拿模型清单要真的启动各引擎的 CLI(秒级),客户端
6
+ // 因此把它整份缓存起来;但额度是会变的(用一次就掉、换个 key 就是另一个账号)。
7
+ // 缓存整份清单会让额度跟着一起冻住 —— 用户看到「额度不刷新」。
8
+ //
9
+ // 这条命令**不探测引擎、不启动任何 CLI**:只读本机票据 / models.json,再发一两条
10
+ // 只读 HTTP 请求(codebuddy 系 2 条、llm 的 MiniMax 各 key 1 条),秒级返回。
11
+ // 客户端把它合并进缓存清单,就能做到「模型清单用缓存、额度每次现查」。
12
+ //
13
+ // 输出:`[{engine, credit_left?, credit_suffix?, model_quota?, checkin_today?, checkin_credit?}]`
14
+ // (字段与 --engines 同名同义;查不到的字段省略,调用方据此保留旧值)。
15
+
16
+ import (
17
+ "context"
18
+ "sync"
19
+ "time"
20
+
21
+ "github.com/darren/magic-agent/internal/agent"
22
+ "github.com/spf13/cobra"
23
+ )
24
+
25
+ // quotaRow `--quotas` 的一行;字段语义与 --engines 对应字段完全一致。
26
+ type quotaRow struct {
27
+ Engine string `json:"engine"`
28
+ CreditLeft string `json:"credit_left,omitempty"`
29
+ CreditSuffix string `json:"credit_suffix,omitempty"`
30
+ ModelQuota map[string]agent.QuotaWindows `json:"model_quota,omitempty"`
31
+ CheckinToday *bool `json:"checkin_today,omitempty"`
32
+ CheckinCredit string `json:"checkin_credit,omitempty"`
33
+ // LoggedIn 该账号槽位是否已登录(只读票据文件,无网络)。登录 / 登出后
34
+ // 下一次打开选择器就能看到行数变化,不用等引擎清单缓存过期。
35
+ LoggedIn *bool `json:"logged_in,omitempty"`
36
+ // Account 登录的账号昵称(票据里的 account.nickname;未登录 / 无昵称时省略)。
37
+ Account string `json:"account,omitempty"`
38
+ }
39
+
40
+ // quotaProbeTimeout 整条命令的上限:额度查询本身各自有 8s 超时,这里再兜一层,
41
+ // 保证客户端不会因为一个挂住的账单接口把选择器拖到几十秒。
42
+ const quotaProbeTimeout = 6 * time.Second
43
+
44
+ // runQuotas 处理 `--quotas`。
45
+ func runQuotas(cmd *cobra.Command) error {
46
+ ctx, cancel := context.WithTimeout(cmd.Context(), quotaProbeTimeout)
47
+ defer cancel()
48
+
49
+ engines := agent.Engines()
50
+ rows := make([]quotaRow, 0, len(engines))
51
+ var mu sync.Mutex
52
+ var wg sync.WaitGroup
53
+ for _, e := range engines {
54
+ cl, hasCredit := e.(agent.CreditLister)
55
+ mq, hasQuota := e.(agent.ModelQuotaLister)
56
+ ck, hasCheckin := e.(agent.Checkiner)
57
+ if !hasCredit && !hasQuota && !hasCheckin {
58
+ continue
59
+ }
60
+ unit := ""
61
+ if u, ok := e.(agent.CreditUnitLister); ok {
62
+ unit = u.CreditUnit()
63
+ }
64
+ i := len(rows)
65
+ rows = append(rows, quotaRow{Engine: e.Name()})
66
+ // 登录态只读票据文件,同步拿(不发网络);未登录的槽位客户端据此隐藏。
67
+ if lc, okl := e.(agent.LoginChecker); okl {
68
+ loggedIn := lc.LoggedIn()
69
+ rows[i].LoggedIn = &loggedIn
70
+ }
71
+ if al, okl := e.(agent.AccountLister); okl {
72
+ rows[i].Account = al.AccountName()
73
+ }
74
+
75
+ if hasCredit {
76
+ wg.Add(1)
77
+ go func(i int, l agent.CreditLister) {
78
+ defer wg.Done()
79
+ left := l.CreditLeft(ctx)
80
+ if left == "" {
81
+ return
82
+ }
83
+ mu.Lock()
84
+ rows[i].CreditLeft = left
85
+ if unit != "" {
86
+ rows[i].CreditSuffix = unit
87
+ }
88
+ mu.Unlock()
89
+ }(i, cl)
90
+ }
91
+ if hasQuota {
92
+ wg.Add(1)
93
+ go func(i int, l agent.ModelQuotaLister) {
94
+ defer wg.Done()
95
+ q := l.ModelQuota(ctx)
96
+ if len(q) == 0 {
97
+ return
98
+ }
99
+ mu.Lock()
100
+ rows[i].ModelQuota = q
101
+ if unit != "" {
102
+ rows[i].CreditSuffix = unit
103
+ }
104
+ mu.Unlock()
105
+ }(i, mq)
106
+ }
107
+ if hasCheckin {
108
+ wg.Add(1)
109
+ go func(i int, c agent.Checkiner) {
110
+ defer wg.Done()
111
+ info, known := c.CheckinStatus(ctx)
112
+ if !known {
113
+ return
114
+ }
115
+ checked := info.CheckedToday
116
+ mu.Lock()
117
+ rows[i].CheckinToday = &checked
118
+ rows[i].CheckinCredit = info.DailyCredit
119
+ mu.Unlock()
120
+ }(i, ck)
121
+ }
122
+ }
123
+ wg.Wait()
124
+ return printJSON(cmd.OutOrStdout(), rows)
125
+ }