magic-agent 1.0.2 → 1.0.46
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 +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- 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 +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -63,6 +63,30 @@ func TestCodexBuildArgsModel(t *testing.T) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// 续接轮的模型不能走 `-m`:`codex exec resume` 的参数表里没有它(同 `-C` 一样会被 clap
|
|
67
|
+
// 以 `unexpected argument` 拒掉、exit 2、stdout 全空 → core 报「empty result · 0.0s」)。
|
|
68
|
+
// 改为配置覆写 `-c model="<m>"` —— codex 帮助里正是拿 model 当 `-c` 的例子。
|
|
69
|
+
func TestCodexResumeModelViaConfigOverride(t *testing.T) {
|
|
70
|
+
dir := t.TempDir()
|
|
71
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
72
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
73
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
74
|
+
SessionID: "th-1",
|
|
75
|
+
Model: "codex/MiniMax-M3",
|
|
76
|
+
Messages: []Message{{Role: "user", Content: "继续"}},
|
|
77
|
+
}); err != nil {
|
|
78
|
+
t.Fatalf("Complete: %v", err)
|
|
79
|
+
}
|
|
80
|
+
data, _ := readFile(log)
|
|
81
|
+
args := string(data)
|
|
82
|
+
if strings.Contains(args, "-m MiniMax-M3") {
|
|
83
|
+
t.Errorf("续接轮不该用 -m(resume 子命令没有这个 flag):%q", args)
|
|
84
|
+
}
|
|
85
|
+
if !strings.Contains(args, `-c model="MiniMax-M3"`) {
|
|
86
|
+
t.Errorf("续接轮的模型应走 -c model=…:%q", args)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
func TestCodexSystemPrompt(t *testing.T) {
|
|
67
91
|
dir := t.TempDir()
|
|
68
92
|
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
@@ -18,10 +18,17 @@ package agent
|
|
|
18
18
|
// 归谁所有:models.json 是 **CLI 自己的**配置文件(CLI 的 models 控制器会读写它)。
|
|
19
19
|
// 所以这里只做**合并**:只补缺失的 id,不删、不改、不动其它字段;无新增时不落盘
|
|
20
20
|
//(避免无谓写盘与 mtime 抖动)。App 缓存一律只读。
|
|
21
|
+
//
|
|
22
|
+
// ⚠️ 硬约束(2026-09-30 补):目标文件**必须是顶层 JSON 对象**才动它。桌面端
|
|
23
|
+
// WorkBuddy 自己那份 `models.json` 顶层是**数组**(LanguageModel[]),与 CLI 的
|
|
24
|
+
// `{availableModels, models}` 不是同一个格式。若把数组当"解析失败的空文件"整体写回,
|
|
25
|
+
// 就是把对方的模型清单替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
|
|
26
|
+
// 撞上这种文件一律**跳过并在 stderr 说明**;覆盖写之前另留 `.magic-agent.bak` 原文。
|
|
21
27
|
|
|
22
28
|
import (
|
|
23
29
|
"encoding/json"
|
|
24
30
|
"errors"
|
|
31
|
+
"fmt"
|
|
25
32
|
"io/fs"
|
|
26
33
|
"os"
|
|
27
34
|
"path/filepath"
|
|
@@ -32,9 +39,38 @@ import (
|
|
|
32
39
|
// customModelsFileName CLI 读自定义模型端点的文件名(见 CLI 的 getModelsPath)。
|
|
33
40
|
const customModelsFileName = "models.json"
|
|
34
41
|
|
|
42
|
+
// customModelsBackupSuffix 覆盖写之前留的原文备份后缀。
|
|
43
|
+
// 一次误写就能把模型清单清空,留一份可回滚的东西。
|
|
44
|
+
const customModelsBackupSuffix = ".magic-agent.bak"
|
|
45
|
+
|
|
35
46
|
// customModelIDPrefix 自定义模型 id 前缀(App 与 CLI 共用这个约定)。
|
|
36
47
|
const customModelIDPrefix = "custom-local:"
|
|
37
48
|
|
|
49
|
+
// StripCustomLocalPrefix 去掉模型 id 的 "custom-local:" 前缀(仅用于 --engines / --contract
|
|
50
|
+
// 展示:codebuddy 系模型的噪点前缀,给客户端渲染用)。-m 实际传值时由
|
|
51
|
+
// codebuddyCore.modelArg 按裸名补回前缀,自定义模型依旧可用(见该函数的说明)。
|
|
52
|
+
// 不修改入参,返回新切片。
|
|
53
|
+
func StripCustomLocalPrefix(models []string) []string {
|
|
54
|
+
out := make([]string, 0, len(models))
|
|
55
|
+
for _, m := range models {
|
|
56
|
+
out = append(out, strings.TrimPrefix(m, customModelIDPrefix))
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// StripCustomLocalPrefixCredits 同 StripCustomLocalPrefix,但作用于「模型 → 倍率」表。
|
|
62
|
+
// custom-local 模型本来就没有倍率(不进表),这里只为键名形态与 models 一致。
|
|
63
|
+
func StripCustomLocalPrefixCredits(credits map[string]string) map[string]string {
|
|
64
|
+
if len(credits) == 0 {
|
|
65
|
+
return credits
|
|
66
|
+
}
|
|
67
|
+
out := make(map[string]string, len(credits))
|
|
68
|
+
for k, v := range credits {
|
|
69
|
+
out[strings.TrimPrefix(k, customModelIDPrefix)] = v
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
38
74
|
// syncCustomModelsFromApp 把 App 合并配置缓存(acc-product-config)里的
|
|
39
75
|
// `custom-local:*` 模型合并进 `<configDir>/models.json`,返回新增条数。
|
|
40
76
|
//
|
|
@@ -53,15 +89,29 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
|
|
|
53
89
|
}
|
|
54
90
|
|
|
55
91
|
path := filepath.Join(configDir, customModelsFileName)
|
|
92
|
+
var raw0 []byte // 改动前的原文(覆盖写之前留备份)
|
|
56
93
|
root := map[string]any{}
|
|
57
94
|
switch b, err := os.ReadFile(path); {
|
|
58
95
|
case err == nil:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
raw0 = b
|
|
97
|
+
/* ⚠️ 只有**顶层是 JSON 对象**才认作 CLI 的 models.json。
|
|
98
|
+
桌面端(WorkBuddy)自己那份 models.json 顶层是**数组**(LanguageModel[]),
|
|
99
|
+
数组解析不进 map[string]any。以前这里把"解析失败"当空文件继续写回
|
|
100
|
+
{models, availableModels} —— 对数组格式来说那等于把对方的模型清单**整体替换**掉:
|
|
101
|
+
官方模型全没了,且不可恢复(原注释里"解析失败时不覆盖未识别的字段"对数组不成立,
|
|
102
|
+
数组根本装不下未知字段)。这种文件不是 CLI 的格式,一律不动。 */
|
|
103
|
+
var doc any
|
|
104
|
+
if err := json.Unmarshal(b, &doc); err != nil {
|
|
105
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 不是合法 JSON(保持原文件不动)\n", path)
|
|
106
|
+
return 0, nil
|
|
64
107
|
}
|
|
108
|
+
m, ok := doc.(map[string]any)
|
|
109
|
+
if !ok {
|
|
110
|
+
fmt.Fprintf(os.Stderr, "magic-agent: 跳过自定义模型端点同步:%s 顶层不是 JSON 对象"+
|
|
111
|
+
"(该文件不是 CLI 的 models.json 格式,覆盖写会毁掉里面的模型清单;保持原文件不动)\n", path)
|
|
112
|
+
return 0, nil
|
|
113
|
+
}
|
|
114
|
+
root = m
|
|
65
115
|
case errors.Is(err, fs.ErrNotExist):
|
|
66
116
|
// 首次同步:新建
|
|
67
117
|
default:
|
|
@@ -126,6 +176,10 @@ func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
|
|
|
126
176
|
return 0, err
|
|
127
177
|
}
|
|
128
178
|
// 含第三方 apiKey:与 CLI 自身凭据同级(仅属主可读)。
|
|
179
|
+
// 覆盖写之前留一份原文备份:一次误写就能清空模型清单,必须可回滚。
|
|
180
|
+
if len(raw0) > 0 {
|
|
181
|
+
_ = os.WriteFile(path+customModelsBackupSuffix, raw0, 0o600)
|
|
182
|
+
}
|
|
129
183
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
130
184
|
return 0, err
|
|
131
185
|
}
|
|
@@ -178,15 +232,96 @@ func collectCustomModels(node any, out map[string]map[string]any) {
|
|
|
178
232
|
// ensureCustomModelEndpoint 仅当本次请求用的是一个自定义模型时,才补一次端点。
|
|
179
233
|
//
|
|
180
234
|
// 为什么不止在 listModels 里同步:用户可能在 App 里刚加了一个自定义模型,就直接
|
|
181
|
-
// `-m custom-local:<新>`
|
|
182
|
-
// best-effort:不同步失败也不影响正常模型(官方模型根本不读 models.json)。
|
|
235
|
+
// `-m custom-local:<新>` 或 `-m <裸名>` 调用(没打开过模型选择器)。这条兜底让那种
|
|
236
|
+
// 路径也能用上。best-effort:不同步失败也不影响正常模型(官方模型根本不读 models.json)。
|
|
183
237
|
func (c codebuddyCore) ensureCustomModelEndpoint(model string) {
|
|
184
|
-
|
|
238
|
+
m := stripModelPrefix(model)
|
|
239
|
+
// 已是 custom-local: 前缀(用户直传全 id),或裸名命中自定义模型 → 需要补端点。
|
|
240
|
+
if !strings.HasPrefix(m, customModelIDPrefix) && c.resolveCustomModelID(model) == "" {
|
|
185
241
|
return
|
|
186
242
|
}
|
|
187
243
|
_, _ = syncCustomModelsFromApp(c.accConfigPath, c.effectiveConfigDir())
|
|
188
244
|
}
|
|
189
245
|
|
|
246
|
+
// resolveCustomModelID 把可能带 "engine/" 或 "custom-local:" 前缀的模型名归一成
|
|
247
|
+
// 自定义模型 id("custom-local:<裸名>")。裸名命中 acc 缓存里的自定义模型、且**不是**
|
|
248
|
+
// 同缓存里的官方模型时才补回前缀 —— 避免把官方模型(如 glm-5.3)误改写成
|
|
249
|
+
// custom-local:glm-5.3(用户自定义了一个同名模型时的退化处理:优先官方)。
|
|
250
|
+
// 命中返回该 id,否则返回 ""(不是自定义模型,不补前缀)。
|
|
251
|
+
//
|
|
252
|
+
// 用途:--engines 展示时剥掉了 custom-local: 前缀(见 StripCustomLocalPrefix),用户
|
|
253
|
+
// 选了之后回传的是裸名;-m 需要带前缀才能真正命中自定义模型端点,故在此补回。
|
|
254
|
+
func (c codebuddyCore) resolveCustomModelID(model string) string {
|
|
255
|
+
name := stripModelPrefix(model)
|
|
256
|
+
name = strings.TrimPrefix(name, customModelIDPrefix)
|
|
257
|
+
if name == "" {
|
|
258
|
+
return ""
|
|
259
|
+
}
|
|
260
|
+
customID := customModelIDPrefix + name
|
|
261
|
+
// 同份 acc 缓存里递归收集全部 id(含嵌套、官方与 custom-local:*):裸名若也是官方模型,
|
|
262
|
+
// 优先官方、不补前缀;仅当它是「纯自定义」(无同名官方)时才补回 custom-local:。
|
|
263
|
+
all := readAllAccModelIDs(c.accConfigPath)
|
|
264
|
+
if all[customID] && !all[name] {
|
|
265
|
+
return customID
|
|
266
|
+
}
|
|
267
|
+
return ""
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// collectAllModelIDs 递归遍历 acc 配置缓存,收集所有模型的 id(官方与 custom-local:*
|
|
271
|
+
// 都收,先到先得)。acc 缓存里官方模型常常嵌在嵌套结构里,故必须递归而非只读顶层。
|
|
272
|
+
func collectAllModelIDs(node any, out map[string]bool) {
|
|
273
|
+
switch v := node.(type) {
|
|
274
|
+
case map[string]any:
|
|
275
|
+
if id, ok := v["id"].(string); ok && id != "" {
|
|
276
|
+
out[id] = true
|
|
277
|
+
}
|
|
278
|
+
for _, child := range v {
|
|
279
|
+
collectAllModelIDs(child, out)
|
|
280
|
+
}
|
|
281
|
+
case []any:
|
|
282
|
+
for _, child := range v {
|
|
283
|
+
collectAllModelIDs(child, out)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// readAllAccModelIDs 返回 acc 配置缓存里出现过的全部模型 id 集合(递归,不区分官方 / 自定义)。
|
|
289
|
+
// 文件不存在 / 解析失败 → 空集。
|
|
290
|
+
func readAllAccModelIDs(accConfigPath string) map[string]bool {
|
|
291
|
+
out := map[string]bool{}
|
|
292
|
+
if accConfigPath == "" {
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
b, err := os.ReadFile(accConfigPath)
|
|
296
|
+
if err != nil {
|
|
297
|
+
return out
|
|
298
|
+
}
|
|
299
|
+
var doc any
|
|
300
|
+
if err := json.Unmarshal(b, &doc); err != nil {
|
|
301
|
+
return out
|
|
302
|
+
}
|
|
303
|
+
collectAllModelIDs(doc, out)
|
|
304
|
+
return out
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// modelArg 返回传给 CLI --model 的值。
|
|
308
|
+
//
|
|
309
|
+
// --engines 展示时剥掉了 custom-local: 前缀(见 StripCustomLocalPrefix),用户选了
|
|
310
|
+
// 自定义模型后回传裸名;这里命中自定义模型就补回 custom-local: 前缀,保证 -m 可用。
|
|
311
|
+
func (c codebuddyCore) modelArg(model string) string {
|
|
312
|
+
m := stripModelPrefix(model)
|
|
313
|
+
if m == "" {
|
|
314
|
+
return ""
|
|
315
|
+
}
|
|
316
|
+
if strings.HasPrefix(m, customModelIDPrefix) {
|
|
317
|
+
return m
|
|
318
|
+
}
|
|
319
|
+
if id := c.resolveCustomModelID(m); id != "" {
|
|
320
|
+
return id
|
|
321
|
+
}
|
|
322
|
+
return m
|
|
323
|
+
}
|
|
324
|
+
|
|
190
325
|
// effectiveConfigDir 本次子进程实际使用的配置目录:用户显式设了 CODEBUDDY_CONFIG_DIR
|
|
191
326
|
// 就跟着走(CLI 也这么读,见 codebuddyAccountEnv),否则用引擎自带的那个。
|
|
192
327
|
func (c codebuddyCore) effectiveConfigDir() string {
|
|
@@ -195,3 +330,120 @@ func (c codebuddyCore) effectiveConfigDir() string {
|
|
|
195
330
|
}
|
|
196
331
|
return c.configDir
|
|
197
332
|
}
|
|
333
|
+
|
|
334
|
+
// ── 事故还原:把被覆盖写坏的桌面端 models.json 修回来 ──────────
|
|
335
|
+
//
|
|
336
|
+
// 背景(2026-09-30 Windows 事故):Windows 上 CODEBUDDY_CONFIG_DIR 可能指向桌面端
|
|
337
|
+
// 数据目录,于是上面的同步把桌面端那份**数组格式**的 models.json 覆盖成了
|
|
338
|
+
// `{availableModels, models}` 对象格式。桌面端解析不了 → 模型清单整个为空,
|
|
339
|
+
// 用户看到「WorkBuddy 的模型都丢失了」。
|
|
340
|
+
//
|
|
341
|
+
// 还原依据(实测比对同一台机器上的两份文件):
|
|
342
|
+
//
|
|
343
|
+
// 桌面端数组条目 {id:"MiniMax-M3", name, vendor, url, apiKey, ...}
|
|
344
|
+
// 我们写的对象条目 {id:"custom-local:MiniMax-M3", name, vendor, url, apiKey, ...}
|
|
345
|
+
//
|
|
346
|
+
// 即**同一批条目、id 差一个 custom-local: 前缀**。所以还原 = 把对象里的 models[]
|
|
347
|
+
// 原样搬成数组,只去掉 id 前缀。官方模型清单不在这个文件里(那是 product.json /
|
|
348
|
+
// acc 缓存的事,桌面端自己拉),所以这里不需要、也不该造官方条目。
|
|
349
|
+
|
|
350
|
+
// DesktopAppHomeOf 返回引擎对应的桌面端数据目录名(空 = 该引擎没有桌面端)。
|
|
351
|
+
func DesktopAppHomeOf(engine string) string {
|
|
352
|
+
switch strings.ToLower(strings.TrimSpace(engine)) {
|
|
353
|
+
case "codebuddy":
|
|
354
|
+
return ".workbuddy"
|
|
355
|
+
case "codebuddy-ai":
|
|
356
|
+
return ".workbuddy-ai"
|
|
357
|
+
}
|
|
358
|
+
return ""
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// DesktopEngines 列出有桌面端数据目录的引擎(`--repair-models` 的可用值,供提示与校验共用)。
|
|
362
|
+
func DesktopEngines() []string { return []string{"codebuddy", "codebuddy-ai"} }
|
|
363
|
+
|
|
364
|
+
// RepairDesktopModels 修复被反向同步写坏的桌面端 models.json。
|
|
365
|
+
// 返回(是否真的做了修复, 给人看的说明, 错误)。没有可修的东西时返回 (false, 说明, nil)。
|
|
366
|
+
func RepairDesktopModels(engine string) (bool, string, error) {
|
|
367
|
+
home := DesktopAppHomeOf(engine)
|
|
368
|
+
if home == "" {
|
|
369
|
+
return false, "", fmt.Errorf(
|
|
370
|
+
"引擎 %q 没有对应的桌面端数据目录,没有可修的 models.json(支持:%s)",
|
|
371
|
+
strings.TrimSpace(engine), strings.Join(DesktopEngines(), " / "))
|
|
372
|
+
}
|
|
373
|
+
dir, err := os.UserHomeDir()
|
|
374
|
+
if err != nil || dir == "" {
|
|
375
|
+
dir = os.Getenv("HOME")
|
|
376
|
+
}
|
|
377
|
+
if dir == "" {
|
|
378
|
+
return false, "", fmt.Errorf("定位用户目录失败,无法找到桌面端 models.json")
|
|
379
|
+
}
|
|
380
|
+
return repairModelsFile(filepath.Join(dir, home))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// repairModelsFile 就地把 appHome/models.json 从「被覆盖写的对象」还原成桌面端数组。
|
|
384
|
+
//
|
|
385
|
+
// 只认**明确的坏形状**(顶层对象 + models[] 全是 custom-local:* 条目)才动手:
|
|
386
|
+
// 已经是数组、不是合法 JSON、或形状不认识,一律原样不动并说明原因。
|
|
387
|
+
func repairModelsFile(appHome string) (bool, string, error) {
|
|
388
|
+
path := filepath.Join(appHome, customModelsFileName)
|
|
389
|
+
b, err := os.ReadFile(path)
|
|
390
|
+
switch {
|
|
391
|
+
case errors.Is(err, fs.ErrNotExist):
|
|
392
|
+
return false, fmt.Sprintf("%s 不存在,无需修复", path), nil
|
|
393
|
+
case err != nil:
|
|
394
|
+
return false, "", err
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
var doc any
|
|
398
|
+
if uerr := json.Unmarshal(b, &doc); uerr != nil {
|
|
399
|
+
return false, fmt.Sprintf("%s 不是合法 JSON,形状对不上,未改动", path), nil
|
|
400
|
+
}
|
|
401
|
+
if _, isArr := doc.([]any); isArr {
|
|
402
|
+
return false, fmt.Sprintf("%s 已是桌面端的数组格式,无需修复", path), nil
|
|
403
|
+
}
|
|
404
|
+
root, ok := doc.(map[string]any)
|
|
405
|
+
if !ok {
|
|
406
|
+
return false, fmt.Sprintf("%s 顶层既不是数组也不是对象,未改动", path), nil
|
|
407
|
+
}
|
|
408
|
+
raw, _ := root["models"].([]any)
|
|
409
|
+
if len(raw) == 0 {
|
|
410
|
+
return false, fmt.Sprintf("%s 里没有 models 数组,不像被我们覆盖写的形状,未改动", path), nil
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
out := make([]any, 0, len(raw))
|
|
414
|
+
for _, it := range raw {
|
|
415
|
+
m, ok := it.(map[string]any)
|
|
416
|
+
if !ok {
|
|
417
|
+
return false, fmt.Sprintf("%s 的 models 里混有非对象条目,未改动", path), nil
|
|
418
|
+
}
|
|
419
|
+
id, _ := m["id"].(string)
|
|
420
|
+
if !strings.HasPrefix(id, customModelIDPrefix) {
|
|
421
|
+
return false, fmt.Sprintf("%s 的 models 里混有非 %s 条目(可能不是我们写坏的),未改动",
|
|
422
|
+
path, customModelIDPrefix), nil
|
|
423
|
+
}
|
|
424
|
+
cp := make(map[string]any, len(m))
|
|
425
|
+
for k, v := range m {
|
|
426
|
+
cp[k] = v
|
|
427
|
+
}
|
|
428
|
+
cp["id"] = strings.TrimPrefix(id, customModelIDPrefix)
|
|
429
|
+
out = append(out, cp)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
nb, merr := json.MarshalIndent(out, "", " ")
|
|
433
|
+
if merr != nil {
|
|
434
|
+
return false, "", merr
|
|
435
|
+
}
|
|
436
|
+
nb = append(nb, '\n')
|
|
437
|
+
|
|
438
|
+
// 先把现场(被写坏的那份)留一份,再落盘。
|
|
439
|
+
broken := path + ".broken.bak"
|
|
440
|
+
if werr := os.WriteFile(broken, b, 0o600); werr != nil {
|
|
441
|
+
return false, "", fmt.Errorf("写备份 %s 失败: %w", broken, werr)
|
|
442
|
+
}
|
|
443
|
+
if werr := os.WriteFile(path, nb, 0o600); werr != nil {
|
|
444
|
+
return false, "", werr
|
|
445
|
+
}
|
|
446
|
+
return true, fmt.Sprintf(
|
|
447
|
+
"已还原 %s:%d 条自定义模型改回桌面端的数组格式(id 去掉 %s 前缀);原文件备份在 %s",
|
|
448
|
+
path, len(out), customModelIDPrefix, broken), nil
|
|
449
|
+
}
|
|
@@ -9,8 +9,11 @@ package agent
|
|
|
9
9
|
// 2. 幂等:无新增时**不落盘**(内容逐字节不变)
|
|
10
10
|
// 3. 只增不改:既有条目与未识别字段原样保留;availableModels 追加不丢
|
|
11
11
|
// 4. 安全兜底:App 缓存缺失 / 条目 disabled → 不建文件、不报错
|
|
12
|
+
// 5. 陌生文件一律不碰:顶层不是 JSON 对象(桌面端是数组)/ 坏 JSON → 逐字节保持原样,
|
|
13
|
+
// 且覆盖写之前一定留 .magic-agent.bak(见 2026-09-30 Windows 事故用例)
|
|
12
14
|
|
|
13
15
|
import (
|
|
16
|
+
"bytes"
|
|
14
17
|
"encoding/json"
|
|
15
18
|
"os"
|
|
16
19
|
"path/filepath"
|
|
@@ -53,6 +56,80 @@ func appCacheFixture() map[string]any {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// StripCustomLocalPrefix 剥掉自定义模型 id 的 "custom-local:" 前缀(仅展示用)。
|
|
60
|
+
func TestStripCustomLocalPrefix(t *testing.T) {
|
|
61
|
+
got := StripCustomLocalPrefix([]string{"hy3", "custom-local:MiniMax-M3", "custom-local:glm-5.3"})
|
|
62
|
+
want := []string{"hy3", "MiniMax-M3", "glm-5.3"}
|
|
63
|
+
if len(got) != len(want) {
|
|
64
|
+
t.Fatalf("StripCustomLocalPrefix = %v want %v", got, want)
|
|
65
|
+
}
|
|
66
|
+
for i := range want {
|
|
67
|
+
if got[i] != want[i] {
|
|
68
|
+
t.Errorf("StripCustomLocalPrefix[%d] = %q want %q (full %v)", i, got[i], want[i], got)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
c := StripCustomLocalPrefixCredits(map[string]string{"hy3": "0.00", "custom-local:X": "0.34"})
|
|
72
|
+
if c["custom-local:X"] != "" || c["X"] != "0.34" || c["hy3"] != "0.00" {
|
|
73
|
+
t.Errorf("StripCustomLocalPrefixCredits = %v", c)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// resolveCustomModelID:裸名命中纯自定义模型时补回 custom-local: 前缀;
|
|
78
|
+
// 裸名同时是官方模型时**不补**(优先官方,避免把 glm-5.3 误写成 custom-local:glm-5.3)。
|
|
79
|
+
func TestResolveCustomModelID(t *testing.T) {
|
|
80
|
+
dir := t.TempDir()
|
|
81
|
+
acc := filepath.Join(dir, "acc.json")
|
|
82
|
+
writeJSON(t, acc, map[string]any{"models": []any{
|
|
83
|
+
map[string]any{"id": "glm-5.3"},
|
|
84
|
+
map[string]any{"id": "custom-local:MiniMax-M3"},
|
|
85
|
+
// 嵌套结构里的官方模型也要能识别(readAllAccModelIDs 递归收集)。
|
|
86
|
+
map[string]any{"wrapped": map[string]any{"id": "deepseek-v4.1-flash"}},
|
|
87
|
+
}})
|
|
88
|
+
c := codebuddyCore{accConfigPath: acc}
|
|
89
|
+
|
|
90
|
+
if got := c.resolveCustomModelID("MiniMax-M3"); got != "custom-local:MiniMax-M3" {
|
|
91
|
+
t.Errorf("resolve MiniMax-M3 = %q want custom-local:MiniMax-M3", got)
|
|
92
|
+
}
|
|
93
|
+
if got := c.resolveCustomModelID("glm-5.3"); got != "" {
|
|
94
|
+
t.Errorf("resolve glm-5.3 = %q want \"\"(优先官方模型,不补前缀)", got)
|
|
95
|
+
}
|
|
96
|
+
// 已带前缀:原样返回。
|
|
97
|
+
if got := c.resolveCustomModelID("custom-local:MiniMax-M3"); got != "custom-local:MiniMax-M3" {
|
|
98
|
+
t.Errorf("resolve custom-local:MiniMax-M3 = %q", got)
|
|
99
|
+
}
|
|
100
|
+
// 既不是官方也不是自定义 → 不补。
|
|
101
|
+
if got := c.resolveCustomModelID("nonexistent-model"); got != "" {
|
|
102
|
+
t.Errorf("resolve nonexistent = %q want \"\"", got)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// modelArg:传给 CLI --model 的值。展示时剥了前缀的自定义模型裸名要补回前缀。
|
|
107
|
+
func TestCodeBuddyModelArg(t *testing.T) {
|
|
108
|
+
dir := t.TempDir()
|
|
109
|
+
acc := filepath.Join(dir, "acc.json")
|
|
110
|
+
writeJSON(t, acc, map[string]any{"models": []any{
|
|
111
|
+
map[string]any{"id": "glm-5.3"},
|
|
112
|
+
map[string]any{"id": "custom-local:MiniMax-M3"},
|
|
113
|
+
}})
|
|
114
|
+
c := codebuddyCore{accConfigPath: acc}
|
|
115
|
+
|
|
116
|
+
cases := []struct {
|
|
117
|
+
in, want string
|
|
118
|
+
}{
|
|
119
|
+
{"hy3", "hy3"}, // 官方:原样
|
|
120
|
+
{"codebuddy/hy3", "hy3"}, // 带 engine/ 前缀:剥掉
|
|
121
|
+
{"glm-5.3", "glm-5.3"}, // 同名官方:不补前缀
|
|
122
|
+
{"MiniMax-M3", "custom-local:MiniMax-M3"}, // 自定义裸名:补前缀
|
|
123
|
+
{"custom-local:MiniMax-M3", "custom-local:MiniMax-M3"}, // 已带前缀:原样
|
|
124
|
+
{"", ""}, // 空:空
|
|
125
|
+
}
|
|
126
|
+
for _, tc := range cases {
|
|
127
|
+
if got := c.modelArg(tc.in); got != tc.want {
|
|
128
|
+
t.Errorf("modelArg(%q) = %q want %q", tc.in, got, tc.want)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
56
133
|
func TestSyncCustomModelsFromApp_FirstSync(t *testing.T) {
|
|
57
134
|
dir := t.TempDir()
|
|
58
135
|
acc := filepath.Join(dir, "acc-product-config-v3.json")
|
|
@@ -182,11 +259,112 @@ func TestSyncCustomModelsFromApp_IdempotentAndMergeOnly(t *testing.T) {
|
|
|
182
259
|
}
|
|
183
260
|
}
|
|
184
261
|
|
|
185
|
-
|
|
262
|
+
// 目标文件顶层不是 JSON 对象(桌面端 WorkBuddy 的 models.json 就是**数组**)时,
|
|
263
|
+
// 必须**一个字都不动**、也不留备份(因为压根没写)。
|
|
264
|
+
//
|
|
265
|
+
// 为什么是关键回归:以前把"解析不进 map"当空文件继续写回 {models, availableModels},
|
|
266
|
+
// 等于把对方的模型清单整体替换成只剩 custom-local 几条 —— 官方模型全丢且不可回滚。
|
|
267
|
+
func TestSyncCustomModelsFromApp_RefusesNonObjectFile(t *testing.T) {
|
|
268
|
+
// 桌面端 models.json 的真实形状:顶层数组,条目是 LanguageModel。
|
|
269
|
+
desktop := []byte(`[
|
|
270
|
+
{
|
|
271
|
+
"id": "MiniMax-M3",
|
|
272
|
+
"name": "MiniMax-M3",
|
|
273
|
+
"vendor": "MiniMax",
|
|
274
|
+
"url": "https://api.minimaxi.com/v1/chat/completions",
|
|
275
|
+
"apiKey": "sk-user-own"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"id": "glm-5.3",
|
|
279
|
+
"name": "glm-5.3",
|
|
280
|
+
"url": "https://open.bigmodel.cn/api/paas/v4",
|
|
281
|
+
"apiKey": "sk-user-own-2"
|
|
282
|
+
}
|
|
283
|
+
]`)
|
|
284
|
+
|
|
285
|
+
cases := []struct {
|
|
286
|
+
name string
|
|
287
|
+
content []byte
|
|
288
|
+
}{
|
|
289
|
+
{"顶层是数组(桌面端格式)", desktop},
|
|
290
|
+
{"坏 JSON", []byte("{oops")},
|
|
291
|
+
{"顶层是字符串", []byte(`"just a string"`)},
|
|
292
|
+
{"顶层是数字", []byte(`42`)},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for _, tc := range cases {
|
|
296
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
297
|
+
dir := t.TempDir()
|
|
298
|
+
acc := filepath.Join(dir, "acc.json")
|
|
299
|
+
writeJSON(t, acc, appCacheFixture())
|
|
300
|
+
cfg := filepath.Join(dir, "config")
|
|
301
|
+
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
302
|
+
t.Fatal(err)
|
|
303
|
+
}
|
|
304
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
305
|
+
if err := os.WriteFile(path, tc.content, 0o600); err != nil {
|
|
306
|
+
t.Fatal(err)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
310
|
+
if err != nil {
|
|
311
|
+
t.Errorf("应静默跳过,不该报错: %v", err)
|
|
312
|
+
}
|
|
313
|
+
if added != 0 {
|
|
314
|
+
t.Errorf("added = %d, want 0", added)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
after, err := os.ReadFile(path)
|
|
318
|
+
if err != nil {
|
|
319
|
+
t.Fatalf("原文件必须还在: %v", err)
|
|
320
|
+
}
|
|
321
|
+
if !bytes.Equal(after, tc.content) {
|
|
322
|
+
t.Errorf("原文件被改写了!\nbefore=%s\nafter =%s", tc.content, after)
|
|
323
|
+
}
|
|
324
|
+
if _, err := os.Stat(path + customModelsBackupSuffix); !os.IsNotExist(err) {
|
|
325
|
+
t.Errorf("没写就不该留备份")
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 真的要覆盖写时,必须先把原文留成 <models.json>.magic-agent.bak(可回滚)。
|
|
332
|
+
func TestSyncCustomModelsFromApp_BacksUpBeforeOverwrite(t *testing.T) {
|
|
186
333
|
dir := t.TempDir()
|
|
334
|
+
acc := filepath.Join(dir, "acc.json")
|
|
335
|
+
writeJSON(t, acc, appCacheFixture())
|
|
187
336
|
cfg := filepath.Join(dir, "config")
|
|
337
|
+
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
338
|
+
t.Fatal(err)
|
|
339
|
+
}
|
|
188
340
|
path := filepath.Join(cfg, customModelsFileName)
|
|
189
341
|
|
|
342
|
+
before := []byte("{\n \"models\": [],\n \"availableModels\": [\"hy3\"]\n}\n")
|
|
343
|
+
if err := os.WriteFile(path, before, 0o600); err != nil {
|
|
344
|
+
t.Fatal(err)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
348
|
+
if err != nil {
|
|
349
|
+
t.Fatal(err)
|
|
350
|
+
}
|
|
351
|
+
if added == 0 {
|
|
352
|
+
t.Fatal("本次应有新增(触发落盘)")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
bak, err := os.ReadFile(path + customModelsBackupSuffix)
|
|
356
|
+
if err != nil {
|
|
357
|
+
t.Fatalf("覆盖写之前应留备份: %v", err)
|
|
358
|
+
}
|
|
359
|
+
if !bytes.Equal(bak, before) {
|
|
360
|
+
t.Errorf("备份内容应为改动前的原文\nwant=%s\ngot =%s", before, bak)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
|
|
365
|
+
dir := t.TempDir()
|
|
366
|
+
cfg := filepath.Join(dir, "config")
|
|
367
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
190
368
|
cases := []struct{ name, acc string }{
|
|
191
369
|
{"acc 路径为空", ""},
|
|
192
370
|
{"acc 文件不存在", filepath.Join(dir, "nope.json")},
|
|
@@ -221,7 +399,17 @@ func TestSyncCustomModelsFromApp_NoSourceNoFile(t *testing.T) {
|
|
|
221
399
|
}
|
|
222
400
|
}
|
|
223
401
|
|
|
224
|
-
|
|
402
|
+
/* 既有 models.json 无法确认为「CLI 自己的格式」时,一律**不碰**,而不是重建。
|
|
403
|
+
*
|
|
404
|
+
* 这条契约是 2026-09-30 改的(Windows 事故)。原行为是「解析失败当空文件,重建」,
|
|
405
|
+
* 理由看起来无害(CLI 自己也这么兜底)。实际后果是把**别人的**文件整体替换掉:
|
|
406
|
+
* 桌面端 WorkBuddy 的 models.json 顶层是数组,数组解析不进 map[string]any,
|
|
407
|
+
* 于是被当成「坏文件」重建 → 里面 61 条官方模型全没了,只剩 8 条 custom-local,
|
|
408
|
+
* 而且没有备份,不可回滚。
|
|
409
|
+
*
|
|
410
|
+
* 宁可少同步一次端点(CLI 会自己报 "has no endpoint url configured"),
|
|
411
|
+
* 也不要毁掉一份不是我们管的配置。 */
|
|
412
|
+
func TestSyncCustomModelsFromApp_CorruptExistingFileLeftUntouched(t *testing.T) {
|
|
225
413
|
dir := t.TempDir()
|
|
226
414
|
acc := filepath.Join(dir, "acc.json")
|
|
227
415
|
writeJSON(t, acc, appCacheFixture())
|
|
@@ -229,16 +417,25 @@ func TestSyncCustomModelsFromApp_CorruptExistingFileRebuilt(t *testing.T) {
|
|
|
229
417
|
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
|
230
418
|
t.Fatal(err)
|
|
231
419
|
}
|
|
232
|
-
|
|
233
|
-
|
|
420
|
+
path := filepath.Join(cfg, customModelsFileName)
|
|
421
|
+
broken := []byte("{oops")
|
|
422
|
+
if err := os.WriteFile(path, broken, 0o600); err != nil {
|
|
234
423
|
t.Fatal(err)
|
|
235
424
|
}
|
|
425
|
+
|
|
236
426
|
added, err := syncCustomModelsFromApp(acc, cfg)
|
|
237
427
|
if err != nil {
|
|
238
|
-
t.Fatalf("
|
|
428
|
+
t.Fatalf("不认识的文件应静默跳过,不该报错: %v", err)
|
|
239
429
|
}
|
|
240
|
-
if added !=
|
|
241
|
-
t.Errorf("added = %d, want
|
|
430
|
+
if added != 0 {
|
|
431
|
+
t.Errorf("added = %d, want 0(不碰未知格式)", added)
|
|
432
|
+
}
|
|
433
|
+
after, rerr := os.ReadFile(path)
|
|
434
|
+
if rerr != nil {
|
|
435
|
+
t.Fatalf("原文件必须还在: %v", rerr)
|
|
436
|
+
}
|
|
437
|
+
if !bytes.Equal(after, broken) {
|
|
438
|
+
t.Errorf("原文件被改写了:%s", after)
|
|
242
439
|
}
|
|
243
440
|
}
|
|
244
441
|
|