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.
- package/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -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/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- 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/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -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 +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- 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 +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- 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/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -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/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -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/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -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/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- 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 +14 -3
|
@@ -3,11 +3,12 @@ package client
|
|
|
3
3
|
/* 后台常驻(`magic-client start|stop|status`)的落地层。
|
|
4
4
|
*
|
|
5
5
|
* ── 为什么需要这一层 ──
|
|
6
|
-
* 手机的连接入口是「一个还在跑的 serve 进程 + 它的端口 + 令牌」。手工 `serve
|
|
7
|
-
*
|
|
8
|
-
* 用户既记不住,也没法把它抄进手机。所以这里把
|
|
6
|
+
* 手机的连接入口是「一个还在跑的 serve 进程 + 它的端口 + 令牌」。手工 `serve`
|
|
7
|
+
* 是**前台**进程:关掉终端就没了;所以这里把
|
|
9
8
|
* 「pid + 端口 + 令牌 + 局域网地址」落成一份 0600 的状态文件,
|
|
10
9
|
* start / status / stop 三个动作围绕同一份事实工作,谁也不再猜。
|
|
10
|
+
* 端口本身也稳了:默认(局域网)未显式给 --addr 时固定绑 DefaultLanAddr(9100)——
|
|
11
|
+
* 手机里存的是「地址 + 令牌」,端口一变配对就作废。
|
|
11
12
|
*
|
|
12
13
|
* ── 为什么由 serve 自己写 ready 文件,而不是父进程猜端口 ──
|
|
13
14
|
* 端口在 bind 之后才知道(`net.Listen("tcp","0.0.0.0:0")`),父进程只能事后读日志正则 ——
|
|
@@ -28,6 +29,7 @@ import (
|
|
|
28
29
|
"os"
|
|
29
30
|
"os/exec"
|
|
30
31
|
"path/filepath"
|
|
32
|
+
"strconv"
|
|
31
33
|
"strings"
|
|
32
34
|
"time"
|
|
33
35
|
|
|
@@ -41,6 +43,13 @@ const (
|
|
|
41
43
|
EnvLog = "MAGIC_CLIENT_LOG"
|
|
42
44
|
)
|
|
43
45
|
|
|
46
|
+
// DefaultLanAddr 默认(局域网)且未显式给 --addr 时的监听地址:固定 9100。
|
|
47
|
+
//
|
|
48
|
+
// 为什么不用 :0 随机:手机里存的是「地址 + 令牌」,端口一变配对就作废,
|
|
49
|
+
// 用户侧现象是「每次重启都得重新配对」。固定端口的代价是可能被占用 ——
|
|
50
|
+
// 那就让 serve 明确报错(--addr 换一个),绝不悄悄换回随机端口骗人。
|
|
51
|
+
const DefaultLanAddr = "0.0.0.0:9100"
|
|
52
|
+
|
|
44
53
|
const (
|
|
45
54
|
// defaultReadyWait 等 serve 报端口的时长。第一次启动要跑 `--contract` 探测引擎,
|
|
46
55
|
// 慢机器上 3~5s 很常见,给到 15s 留余量。
|
|
@@ -51,6 +60,48 @@ const (
|
|
|
51
60
|
probeTimeout = 1500 * time.Millisecond
|
|
52
61
|
)
|
|
53
62
|
|
|
63
|
+
/* SelfStateName 前台 `serve` 自己登记的那份状态文件名(落在 profile 的 DataDir 下)。
|
|
64
|
+
*
|
|
65
|
+
* ── 为什么前台 serve 也要落状态(2026-10-26 用户:「通过 magic-test 启动的无法在移动端
|
|
66
|
+
* 点击重启服务重启」)──
|
|
67
|
+
* `POST /desk/restart` 原本只认 `magic-client start` 写的那份 client.json,前台 serve
|
|
68
|
+
* 什么都不写,于是手机点重启一律 409「当前实例不是后台启动的」。可是 magic-test 的桥接
|
|
69
|
+
* (desktop/magic-client-bridge.cjs)正是用 `spawn(bin, ['serve', ...])` 起的实例 ——
|
|
70
|
+
* 用户手机上连的就是它,按钮却永远点不动。
|
|
71
|
+
*
|
|
72
|
+
* 修法不是把前台硬改成后台(那会丢掉「壳退出即服务退出」的托管语义),而是让 serve
|
|
73
|
+
* **如实登记自己是谁、怎么起的**:重启助手照这份记录停旧起新即可。
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ 必须是**另一个文件名**,不能复用 client.json:那份是 `start` 的唯一实例记录,
|
|
76
|
+
* 两者混写会出现「start 起的实例被前台 serve 的登记覆盖」——重启时按它重建参数,
|
|
77
|
+
* 就会用一个根本不是 start 参数拼出来的命令行去起新实例。 */
|
|
78
|
+
const SelfStateName = "serve.json"
|
|
79
|
+
|
|
80
|
+
// SelfStatePath 前台 serve 的状态文件路径:<profile 数据目录>/serve.json。
|
|
81
|
+
//
|
|
82
|
+
// 按 DataDir 分而不是按 ~/.magic-client 分,理由与单实例锁一致:同机可能同时跑着
|
|
83
|
+
// 好几个不同应用(magic-test 的 8977、magic-agent 自己的 9100),数据目录不同、
|
|
84
|
+
// 令牌不同,各登记各的,谁也认领不到谁。
|
|
85
|
+
func SelfStatePath(dataDir string) string {
|
|
86
|
+
d := strings.TrimSpace(expandHome(dataDir))
|
|
87
|
+
if d == "" {
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
return filepath.Join(d, SelfStateName)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// HostAlive 托管父进程是否还在(pid <= 0 = 没人托管,恒 true)。
|
|
94
|
+
//
|
|
95
|
+
// 重启后的新实例是 detach 出去的(不然它会被旧实例的退出带走),但它的「主人」仍然是
|
|
96
|
+
// 拉起这台服务的桌面壳:壳退出了服务就该跟着退,否则会留下一个孤儿占着端口与单实例锁,
|
|
97
|
+
// 下次开壳直接「插件启动即退出」。见 service.go 的 WatchHost。
|
|
98
|
+
func HostAlive(pid int) bool {
|
|
99
|
+
if pid <= 0 {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
return agent.PIDAlive(pid)
|
|
103
|
+
}
|
|
104
|
+
|
|
54
105
|
// DefaultStatePath 状态文件默认位置:~/.magic-client/client.json。
|
|
55
106
|
func DefaultStatePath() string {
|
|
56
107
|
if p := strings.TrimSpace(os.Getenv(EnvState)); p != "" {
|
|
@@ -164,6 +215,23 @@ type DaemonState struct {
|
|
|
164
215
|
LogPath string `json:"logPath"`
|
|
165
216
|
StatePath string `json:"statePath"`
|
|
166
217
|
Exe string `json:"exe"`
|
|
218
|
+
/* 以下三个是 `restart` 重建启动参数用的(2026-10-24,移动端「我的 → 重启服务」)。
|
|
219
|
+
只增字段:老状态文件没有它们 → 空串,重启时各走自己的默认(地址按 Lan 推、UA 内置、
|
|
220
|
+
core 从 PATH 找),行为与旧版 start 一致。 */
|
|
221
|
+
Addr string `json:"addr,omitempty"`
|
|
222
|
+
BrowseUA string `json:"browseUA,omitempty"`
|
|
223
|
+
MagicAgent string `json:"magicAgent,omitempty"`
|
|
224
|
+
// AnyOrigin serve 那次是否对任意 Origin 回 CORS 头(`--allow-any-origin` 或 `--lan`)。
|
|
225
|
+
// 重启 / 升级后原地拉起时要原样带上,否则升级完手机页面突然跨域失败。
|
|
226
|
+
AnyOrigin bool `json:"anyOrigin,omitempty"`
|
|
227
|
+
/* HostPID 托管这台服务的父进程(桌面壳 / 测试宿主)。
|
|
228
|
+
*
|
|
229
|
+
* 2026-10-26 加:重启后的新实例必须 detach(否则旧实例退出会把它一起带走),可
|
|
230
|
+
* detach 之后它就不在壳的进程组里了 —— 壳退出时 `r.stop()` 杀不到它,留下一个
|
|
231
|
+
* 孤儿占着端口与单实例锁,下次开壳就是「插件启动即退出」。
|
|
232
|
+
* 所以把「主人是谁」记下来,由服务自己盯着:主人没了就优雅退出(见 WatchHost)。
|
|
233
|
+
* 0 = 没人托管(`magic-client start` 起的常驻实例),不盯。 */
|
|
234
|
+
HostPID int `json:"hostPid,omitempty"`
|
|
167
235
|
}
|
|
168
236
|
|
|
169
237
|
// Base 本机根地址(不带令牌)。
|
|
@@ -190,6 +258,60 @@ func writeDaemonState(path string, st *DaemonState) error {
|
|
|
190
258
|
return os.Rename(tmp, path)
|
|
191
259
|
}
|
|
192
260
|
|
|
261
|
+
// WriteSelfState 前台 `serve` 监听成功后,把自己是谁、怎么起的登记下来。
|
|
262
|
+
//
|
|
263
|
+
// 与 writeDaemonState 同一套落盘规则(0600 原子写),但**写到 serve.json**,
|
|
264
|
+
// 绝不碰 `start` 独占的 client.json(理由见 SelfStateName)。
|
|
265
|
+
//
|
|
266
|
+
// 记的东西要和 `start` 那份一样全 —— 重启助手会拿它当**唯一的启动参数来源**重建
|
|
267
|
+
// 命令行(RestartDaemon → StartDaemon),少记一项新实例就会少一个 flag:
|
|
268
|
+
// 少了 Profile ⇒ 数据目录/业务模块全变;少了 Token ⇒ 手机里存的地址立刻作废;
|
|
269
|
+
// 少了 Addr ⇒ 换端口,手机再也连不上。
|
|
270
|
+
//
|
|
271
|
+
// 返回路径,调用方负责在退出时删掉(见 RemoveSelfState)。
|
|
272
|
+
func WriteSelfState(dataDir string, st *DaemonState) (string, error) {
|
|
273
|
+
return WriteSelfStateAt("", dataDir, st)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// WriteSelfStateAt 同上,但允许显式指定完整路径(`--state`)。
|
|
277
|
+
// explicit 空 → 用 dataDir 推出来的 SelfStatePath。
|
|
278
|
+
func WriteSelfStateAt(explicit, dataDir string, st *DaemonState) (string, error) {
|
|
279
|
+
path := strings.TrimSpace(expandHome(explicit))
|
|
280
|
+
if path == "" {
|
|
281
|
+
path = SelfStatePath(dataDir)
|
|
282
|
+
}
|
|
283
|
+
if path == "" {
|
|
284
|
+
return "", fmt.Errorf("没有数据目录:无法登记前台实例状态")
|
|
285
|
+
}
|
|
286
|
+
if st == nil {
|
|
287
|
+
return "", fmt.Errorf("状态为空")
|
|
288
|
+
}
|
|
289
|
+
if st.PID == 0 {
|
|
290
|
+
st.PID = os.Getpid()
|
|
291
|
+
}
|
|
292
|
+
// 状态文件自己那份路径要记对:重启助手按它找回来(见 selfDaemonState)。
|
|
293
|
+
st.StatePath = path
|
|
294
|
+
if err := writeDaemonState(path, st); err != nil {
|
|
295
|
+
return "", err
|
|
296
|
+
}
|
|
297
|
+
return path, nil
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// RemoveSelfState 退出时删掉自己那份登记。
|
|
301
|
+
//
|
|
302
|
+
// 只删「pid 还是我」的那份:重启链路里旧实例退出时,新实例可能**已经**登记好了
|
|
303
|
+
// (它先监听成功才轮到我们收尾),这时删掉就等于把新实例弄成"没有状态"的孤儿 ——
|
|
304
|
+
// 手机再点重启又变成 409。判断错了会留下陈旧登记,但那比删掉活人的准。
|
|
305
|
+
func RemoveSelfState(path string) {
|
|
306
|
+
if strings.TrimSpace(path) == "" {
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if st, err := ReadDaemonState(path); err == nil && st != nil && st.PID != os.Getpid() {
|
|
310
|
+
return // 已经不是我的了(新实例接管),别动
|
|
311
|
+
}
|
|
312
|
+
_ = os.Remove(path)
|
|
313
|
+
}
|
|
314
|
+
|
|
193
315
|
// ReadDaemonState 读状态文件:不存在 → (nil, nil)。
|
|
194
316
|
func ReadDaemonState(path string) (*DaemonState, error) {
|
|
195
317
|
if strings.TrimSpace(path) == "" {
|
|
@@ -299,6 +421,12 @@ type StartOptions struct {
|
|
|
299
421
|
Token string
|
|
300
422
|
ProxyAddr string
|
|
301
423
|
BrowseUA string
|
|
424
|
+
/* AnyOrigin 新实例要不要对任意 Origin 回 CORS 头。由 `restart` / 升级后重启按状态
|
|
425
|
+
文件原样带回(`--lan` 时 create 自己也会开,这里不重复造条件,只是别丢)。 */
|
|
426
|
+
AnyOrigin bool
|
|
427
|
+
/* HostPID 托管父进程(见 DaemonState.HostPID)。0 = 没人托管。
|
|
428
|
+
`restart` 按状态文件原样带回,于是重启出来的新实例继续盯着同一个主人。 */
|
|
429
|
+
HostPID int
|
|
302
430
|
Lan bool
|
|
303
431
|
StatePath string
|
|
304
432
|
LogPath string
|
|
@@ -368,6 +496,14 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
|
|
|
368
496
|
defer logf.Close()
|
|
369
497
|
|
|
370
498
|
args := []string{"serve", "--ready-file", readyPath, "--token", tok}
|
|
499
|
+
/* --state(2026-10-26):告诉子进程「哪份状态文件是你自己的」。
|
|
500
|
+
两个用处,缺一不可:
|
|
501
|
+
· 退出时清理 —— 重启出来的实例由 WatchHost 收摊(宿主退出),它不走 StopDaemon,
|
|
502
|
+
没人删这份登记,留着就是一个指向死 pid 的陈旧文件;
|
|
503
|
+
· 认领自己 —— selfDaemonState 按这条路径找回参数,重启/升级才能落到自己身上。
|
|
504
|
+
不传的话子进程只知道 ready 文件,对「我那份登记在哪」一无所知(实测:重启后
|
|
505
|
+
serve.json 残留 pid 已死的旧记录)。 */
|
|
506
|
+
args = append(args, "--state", statePath)
|
|
371
507
|
appendIf := func(flag, v string) {
|
|
372
508
|
if v = strings.TrimSpace(v); v != "" {
|
|
373
509
|
args = append(args, flag, v)
|
|
@@ -380,8 +516,17 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
|
|
|
380
516
|
appendIf("--proxy-addr", o.ProxyAddr)
|
|
381
517
|
appendIf("--browse-ua", o.BrowseUA)
|
|
382
518
|
appendIf("--magic-agent", o.MagicAgent)
|
|
519
|
+
if o.AnyOrigin {
|
|
520
|
+
args = append(args, "--allow-any-origin")
|
|
521
|
+
}
|
|
522
|
+
if o.HostPID > 0 {
|
|
523
|
+
args = append(args, "--host-pid", strconv.Itoa(o.HostPID))
|
|
524
|
+
}
|
|
383
525
|
if o.Lan {
|
|
384
526
|
args = append(args, "--lan")
|
|
527
|
+
} else {
|
|
528
|
+
// serve 的默认已改为局域网;显式传 --local 才能关掉。
|
|
529
|
+
args = append(args, "--local")
|
|
385
530
|
}
|
|
386
531
|
|
|
387
532
|
cmd := exec.Command(exe, args...)
|
|
@@ -422,20 +567,25 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
|
|
|
422
567
|
_ = os.Remove(readyPath)
|
|
423
568
|
|
|
424
569
|
st := &DaemonState{
|
|
425
|
-
PID:
|
|
426
|
-
Port:
|
|
427
|
-
URL:
|
|
428
|
-
LanAddrs:
|
|
429
|
-
Lan:
|
|
430
|
-
Profile:
|
|
431
|
-
UIRoot:
|
|
432
|
-
MobileDir:
|
|
433
|
-
ProxyAddr:
|
|
434
|
-
Token:
|
|
435
|
-
StartedAt:
|
|
436
|
-
LogPath:
|
|
437
|
-
StatePath:
|
|
438
|
-
Exe:
|
|
570
|
+
PID: info.PID,
|
|
571
|
+
Port: info.Port,
|
|
572
|
+
URL: info.URL,
|
|
573
|
+
LanAddrs: info.LanAddrs,
|
|
574
|
+
Lan: info.Lan,
|
|
575
|
+
Profile: o.Profile,
|
|
576
|
+
UIRoot: o.UIRoot,
|
|
577
|
+
MobileDir: o.MobileDir,
|
|
578
|
+
ProxyAddr: info.ProxyAddr,
|
|
579
|
+
Token: tok,
|
|
580
|
+
StartedAt: time.Now(),
|
|
581
|
+
LogPath: logPath,
|
|
582
|
+
StatePath: statePath,
|
|
583
|
+
Exe: exe,
|
|
584
|
+
Addr: o.Addr,
|
|
585
|
+
BrowseUA: o.BrowseUA,
|
|
586
|
+
MagicAgent: o.MagicAgent,
|
|
587
|
+
AnyOrigin: o.AnyOrigin,
|
|
588
|
+
HostPID: o.HostPID,
|
|
439
589
|
}
|
|
440
590
|
if st.PID == 0 {
|
|
441
591
|
st.PID = cmd.Process.Pid
|
|
@@ -449,6 +599,40 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
|
|
|
449
599
|
// StopOptions `stop` 的参数。
|
|
450
600
|
type StopOptions struct {
|
|
451
601
|
StatePath string
|
|
602
|
+
/* AlsoLockHolder(2026-10-26):状态文件里没有 / 指向的进程已死时,再按**单实例锁**
|
|
603
|
+
里记的 pid 试一次。
|
|
604
|
+
|
|
605
|
+
为什么必须有这一步:`magic-client serve`(前台)与旧版(不认锁、可执行文件已变成
|
|
606
|
+
*.stale 的那些)都**不写状态文件**,于是 `stop` 只读状态文件就永远「本来就没在跑」,
|
|
607
|
+
而 8977 / 54428 上实实在在还有两个进程占着隧道 —— 用户此刻正是这个现场:
|
|
608
|
+
手机连着旧的、新版起不来、`stop` 又停不掉,只能手工 kill。
|
|
609
|
+
|
|
610
|
+
`restart` 不传这个(它必须严格按状态文件重建参数,顺手停掉一个前台实例会把
|
|
611
|
+
用户手上的调试会话一起干掉)。 */
|
|
612
|
+
AlsoLockHolder bool
|
|
613
|
+
/* DataDir(2026-10-26):单实例锁的位置。空 → 从状态文件所在目录推(老调用方行为)。
|
|
614
|
+
与 profile 同源 —— 锁是按数据目录分的,不知道是哪份目录就不知道该停谁。 */
|
|
615
|
+
DataDir string
|
|
616
|
+
/* AppID(2026-10-26):AppID 级占用登记的位置。有了它才能停掉「同一应用换了
|
|
617
|
+
数据目录在跑」的那一份 —— 那种情况下 DataDir 锁查不到它(在另一个目录)。 */
|
|
618
|
+
AppID string
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// stopPID 发 SIGTERM,等 stopGrace,还在就 SIGKILL。返回是否真的停了。
|
|
622
|
+
func stopPID(pid int) bool {
|
|
623
|
+
if !agent.PIDAlive(pid) {
|
|
624
|
+
return false
|
|
625
|
+
}
|
|
626
|
+
_ = agent.TerminatePID(pid, false)
|
|
627
|
+
deadline := time.Now().Add(stopGrace)
|
|
628
|
+
for time.Now().Before(deadline) {
|
|
629
|
+
if !agent.PIDAlive(pid) {
|
|
630
|
+
return true
|
|
631
|
+
}
|
|
632
|
+
time.Sleep(50 * time.Millisecond)
|
|
633
|
+
}
|
|
634
|
+
_ = agent.KillPID(pid, false)
|
|
635
|
+
return true
|
|
452
636
|
}
|
|
453
637
|
|
|
454
638
|
// StopDaemon 停掉后台实例。
|
|
@@ -464,26 +648,106 @@ func StopDaemon(o StopOptions) (bool, *DaemonState, error) {
|
|
|
464
648
|
if err != nil {
|
|
465
649
|
return false, nil, err
|
|
466
650
|
}
|
|
467
|
-
if st == nil {
|
|
468
|
-
|
|
651
|
+
if st == nil || !agent.PIDAlive(st.PID) {
|
|
652
|
+
if st != nil {
|
|
653
|
+
_ = os.Remove(statePath)
|
|
654
|
+
}
|
|
655
|
+
/* 状态文件这条路走不通:还有一份不写状态文件的实例(前台 serve / 旧版 .stale)
|
|
656
|
+
占着锁的话,用户唯一的自救方式就是它。停掉并清锁 —— 不清的话下一次启动
|
|
657
|
+
会因为「锁的持有者已死」走回收路径,虽然也能起来,但日志里会多一段困惑。 */
|
|
658
|
+
if o.AlsoLockHolder {
|
|
659
|
+
/* 两条路都要查:先按数据目录(最常见),再按 AppID 登记(治「同一应用
|
|
660
|
+
换了目录在跑」—— 那一份的 DataDir 锁根本不在我们要找的目录里)。 */
|
|
661
|
+
type lockTarget struct{ path string }
|
|
662
|
+
var targets []lockTarget
|
|
663
|
+
if p := DefaultLockPath(o.DataDir); p != "" {
|
|
664
|
+
targets = append(targets, lockTarget{p})
|
|
665
|
+
}
|
|
666
|
+
if p := appIDLockPath(o.AppID); p != "" && p != DefaultLockPath(o.DataDir) {
|
|
667
|
+
targets = append(targets, lockTarget{p})
|
|
668
|
+
}
|
|
669
|
+
for _, t := range targets {
|
|
670
|
+
h := ReadSingleInstanceHolder(t.path)
|
|
671
|
+
if h == nil || h.PID == os.Getpid() || !stopPID(h.PID) {
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
_ = os.Remove(t.path)
|
|
675
|
+
// 顺手把它的锁也清掉:不清的话它那个目录下次启动会撞「持有者已死」。
|
|
676
|
+
if h.DataDir != "" {
|
|
677
|
+
_ = os.Remove(DefaultLockPath(h.DataDir))
|
|
678
|
+
}
|
|
679
|
+
return true, &DaemonState{
|
|
680
|
+
PID: h.PID, Port: h.Port, Exe: h.Exe,
|
|
681
|
+
}, nil
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if st == nil {
|
|
685
|
+
return false, nil, nil
|
|
686
|
+
}
|
|
687
|
+
return false, st, nil
|
|
469
688
|
}
|
|
470
|
-
if
|
|
689
|
+
if stopPID(st.PID) {
|
|
471
690
|
_ = os.Remove(statePath)
|
|
472
|
-
return
|
|
691
|
+
return true, st, nil
|
|
473
692
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
693
|
+
return false, st, nil
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// RestartOptions `restart` 的参数。
|
|
697
|
+
type RestartOptions struct {
|
|
698
|
+
StatePath string
|
|
699
|
+
// Delay 停旧实例之前等多久。服务端「自重启」用它先把 HTTP 响应写回手机
|
|
700
|
+
// (响应一发完,本进程就会在 Delay 之后被 TerminatePID 带走)。
|
|
701
|
+
Delay time.Duration
|
|
702
|
+
// DataDir 该实例的数据目录(决定单实例锁的位置)。
|
|
703
|
+
DataDir string
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// RestartDaemon 按**当前实例自己的启动参数**重启:读状态文件 → 停旧实例 → 拉起新实例。
|
|
707
|
+
//
|
|
708
|
+
// 为什么参数要从状态文件重建(2026-10-24,移动端「我的」里加了「重启服务」):
|
|
709
|
+
// 发起重启的是手机,它只知道「连着的那个实例」,不知道这台机器当初是怎么起来的
|
|
710
|
+
// (端口、令牌、界面目录、profile、SOCKS5…)。状态文件里都记着,照着起即可 ——
|
|
711
|
+
// 令牌沿用,手机里存的「地址 + 令牌」不会因为重启作废。
|
|
712
|
+
//
|
|
713
|
+
// 只能用于后台实例(`magic-client start`):前台 `serve` 不写状态文件,无从得知参数。
|
|
714
|
+
func RestartDaemon(o RestartOptions) (*DaemonState, error) {
|
|
715
|
+
statePath := strings.TrimSpace(o.StatePath)
|
|
716
|
+
if statePath == "" {
|
|
717
|
+
statePath = DefaultStatePath()
|
|
718
|
+
}
|
|
719
|
+
prev, err := ReadDaemonState(statePath)
|
|
720
|
+
if err != nil {
|
|
721
|
+
return nil, err
|
|
722
|
+
}
|
|
723
|
+
if prev == nil {
|
|
724
|
+
return nil, fmt.Errorf("没有后台实例的状态文件(%s):重启只支持 `magic-client start` 起的实例", statePath)
|
|
725
|
+
}
|
|
726
|
+
if o.Delay > 0 {
|
|
727
|
+
time.Sleep(o.Delay)
|
|
728
|
+
}
|
|
729
|
+
if _, _, err := StopDaemon(StopOptions{StatePath: statePath, DataDir: o.DataDir}); err != nil {
|
|
730
|
+
return nil, err
|
|
483
731
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
732
|
+
/* 旧进程退出到端口真正释放之间还有一小段窗口(Windows 上关句柄不是瞬时),
|
|
733
|
+
不等这一下,新实例会立刻撞「地址已在使用」—— 也就是手机上那句「重启后连不上」。 */
|
|
734
|
+
time.Sleep(300 * time.Millisecond)
|
|
735
|
+
return StartDaemon(StartOptions{
|
|
736
|
+
Exe: prev.Exe,
|
|
737
|
+
MagicAgent: prev.MagicAgent,
|
|
738
|
+
Profile: prev.Profile,
|
|
739
|
+
UIRoot: prev.UIRoot,
|
|
740
|
+
MobileDir: prev.MobileDir,
|
|
741
|
+
Addr: prev.Addr,
|
|
742
|
+
Token: prev.Token,
|
|
743
|
+
ProxyAddr: prev.ProxyAddr,
|
|
744
|
+
BrowseUA: prev.BrowseUA,
|
|
745
|
+
AnyOrigin: prev.AnyOrigin,
|
|
746
|
+
HostPID: prev.HostPID,
|
|
747
|
+
Lan: prev.Lan,
|
|
748
|
+
StatePath: statePath,
|
|
749
|
+
LogPath: prev.LogPath,
|
|
750
|
+
})
|
|
487
751
|
}
|
|
488
752
|
|
|
489
753
|
// DaemonStatus 一份对外可读的运行状态(给 `status` 与排障用)。
|
|
@@ -9,6 +9,8 @@ package client
|
|
|
9
9
|
// 3) status 的判据是「进程在 **且** 健康检查答得上」,不是只看 pid。
|
|
10
10
|
|
|
11
11
|
import (
|
|
12
|
+
"io"
|
|
13
|
+
"log"
|
|
12
14
|
"net/http"
|
|
13
15
|
"net/http/httptest"
|
|
14
16
|
"os"
|
|
@@ -198,6 +200,37 @@ func TestStopClearsStaleState(t *testing.T) {
|
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
// RestartDaemon 没有状态文件时必须在**停任何东西之前**失败:前台 serve 起的实例没有这份
|
|
204
|
+
// 文件,参数无从重建;硬重启会把「不知道参数」变成「先把服务停掉再报错」。
|
|
205
|
+
func TestRestartDaemonNeedsStateFile(t *testing.T) {
|
|
206
|
+
_, err := RestartDaemon(RestartOptions{StatePath: filepath.Join(t.TempDir(), "nope.json")})
|
|
207
|
+
if err == nil {
|
|
208
|
+
t.Fatal("没有状态文件时应当报错(不能猜参数、也不能先停实例)")
|
|
209
|
+
}
|
|
210
|
+
if !strings.Contains(err.Error(), "状态文件") {
|
|
211
|
+
t.Errorf("错误信息要能落地(说明缺的是状态文件):%v", err)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// POST /desk/restart 的两道闸:只收 POST;状态文件不指向本进程时 409。
|
|
216
|
+
// 后者是安全阀 —— 同机可能跑着两个实例,照着别人的状态文件重启会重启**别人**。
|
|
217
|
+
func TestHandleRestartGuards(t *testing.T) {
|
|
218
|
+
t.Setenv(EnvState, filepath.Join(t.TempDir(), "client.json"))
|
|
219
|
+
s := &Service{log: log.New(io.Discard, "", 0)}
|
|
220
|
+
|
|
221
|
+
rec := httptest.NewRecorder()
|
|
222
|
+
s.handleRestart(rec, httptest.NewRequest(http.MethodGet, "/desk/restart", nil))
|
|
223
|
+
if rec.Code != http.StatusMethodNotAllowed {
|
|
224
|
+
t.Errorf("GET 的状态码 = %d,want 405", rec.Code)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
rec = httptest.NewRecorder()
|
|
228
|
+
s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
|
|
229
|
+
if rec.Code != http.StatusConflict {
|
|
230
|
+
t.Errorf("没有指向本进程的状态文件时 = %d,want 409(不许乱重启)", rec.Code)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
201
234
|
func portOf(t *testing.T, rawURL string) int {
|
|
202
235
|
t.Helper()
|
|
203
236
|
i := strings.LastIndex(rawURL, ":")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// engines_checkin_test.go - 领取积分后同步引擎清单兜底缓存(避免重开弹层又显示「领取」)。
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPatchEnginesCacheAfterCheckin(t *testing.T) {
|
|
13
|
+
home := t.TempDir()
|
|
14
|
+
// Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机路径。
|
|
15
|
+
t.Setenv("HOME", home)
|
|
16
|
+
t.Setenv("USERPROFILE", home)
|
|
17
|
+
|
|
18
|
+
p, err := enginesCachePath()
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
before := `[{"engine":"codebuddy","ok":true,"checkin_today":false,"checkin_credit":"100","credit_left":"5830.7"},` +
|
|
26
|
+
`{"engine":"trae","ok":true}]`
|
|
27
|
+
if err := os.WriteFile(p, []byte(before), 0o600); err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 业务失败(今天已签)也要把状态翻成已领;余额沿用旧值。
|
|
32
|
+
patchEnginesCacheAfterCheckin([]byte(`{"engine":"codebuddy","ok":false,"checked_today":true,"message":"今天已签到"}`))
|
|
33
|
+
|
|
34
|
+
raw, err := os.ReadFile(p)
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatal(err)
|
|
37
|
+
}
|
|
38
|
+
var rows []map[string]any
|
|
39
|
+
if err := json.Unmarshal(raw, &rows); err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
if len(rows) != 2 {
|
|
43
|
+
t.Fatalf("rows = %d, want 2", len(rows))
|
|
44
|
+
}
|
|
45
|
+
if rows[0]["checkin_today"] != true {
|
|
46
|
+
t.Errorf("checkin_today = %v, want true", rows[0]["checkin_today"])
|
|
47
|
+
}
|
|
48
|
+
if rows[0]["checkin_credit"] != "" {
|
|
49
|
+
t.Errorf("checkin_credit = %v, want 空串", rows[0]["checkin_credit"])
|
|
50
|
+
}
|
|
51
|
+
if rows[0]["credit_left"] != "5830.7" {
|
|
52
|
+
t.Errorf("credit_left 应保留旧值,实际 %v", rows[0]["credit_left"])
|
|
53
|
+
}
|
|
54
|
+
if rows[1]["engine"] != "trae" {
|
|
55
|
+
t.Errorf("其他引擎行不应被改动:%v", rows[1])
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// fallback_flags_test.go - 主备(2026-10-24)在插件侧的翻译:
|
|
4
|
+
// /desk/ask 的 fallbackEngine / fallbackModel 必须变成 core 的 --fallback-engine / --fallback-model。
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"net/http"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPrepareAskFallbackFlags(t *testing.T) {
|
|
13
|
+
svc := &Service{opts: Options{Profile: DefaultProfile()}}
|
|
14
|
+
_, args, st, err := svc.prepareAsk(askRequest{
|
|
15
|
+
Engine: "claude",
|
|
16
|
+
Prompt: "hi",
|
|
17
|
+
FallbackEngine: "codebuddy",
|
|
18
|
+
FallbackModel: "hy3",
|
|
19
|
+
})
|
|
20
|
+
if err != nil {
|
|
21
|
+
t.Fatalf("prepareAsk: %v (status=%d)", err, st)
|
|
22
|
+
}
|
|
23
|
+
joined := strings.Join(args, " ")
|
|
24
|
+
if !strings.Contains(joined, "--fallback-engine codebuddy") {
|
|
25
|
+
t.Fatalf("备用引擎没透传:%v", args)
|
|
26
|
+
}
|
|
27
|
+
if !strings.Contains(joined, "--fallback-model hy3") {
|
|
28
|
+
t.Fatalf("备用模型没透传:%v", args)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestPrepareAskFallbackWhitelist(t *testing.T) {
|
|
33
|
+
prof := DefaultProfile()
|
|
34
|
+
prof.Engines = []string{"claude"}
|
|
35
|
+
svc := &Service{opts: Options{Profile: prof}}
|
|
36
|
+
_, _, st, err := svc.prepareAsk(askRequest{
|
|
37
|
+
Engine: "claude",
|
|
38
|
+
Prompt: "hi",
|
|
39
|
+
FallbackEngine: "codebuddy",
|
|
40
|
+
})
|
|
41
|
+
if err == nil || st != http.StatusForbidden {
|
|
42
|
+
t.Fatalf("备用引擎不在白名单时应 403,得到 st=%d err=%v", st, err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestJobAskRequestKeepsFallback(t *testing.T) {
|
|
47
|
+
// 作业会落盘并在真正开跑时还原:备用配置必须跟着 Job 走,
|
|
48
|
+
// 否则「直接提交能降级、排队后不能」。
|
|
49
|
+
req := (&Job{Engine: "dsh", Prompt: "hi", FallbackEngine: "codebuddy", FallbackModel: "hy3"}).askRequest()
|
|
50
|
+
if req.FallbackEngine != "codebuddy" || req.FallbackModel != "hy3" {
|
|
51
|
+
t.Fatalf("作业还原丢了备用:%+v", req)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestPrepareAskFallbackIgnoredWhenEmpty(t *testing.T) {
|
|
56
|
+
svc := &Service{opts: Options{Profile: DefaultProfile()}}
|
|
57
|
+
_, args, _, err := svc.prepareAsk(askRequest{Engine: "claude", Prompt: "hi"})
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatalf("prepareAsk: %v", err)
|
|
60
|
+
}
|
|
61
|
+
if strings.Contains(strings.Join(args, " "), "--fallback") {
|
|
62
|
+
t.Fatalf("没配备用不该下发 fallback 参数:%v", args)
|
|
63
|
+
}
|
|
64
|
+
}
|