magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -65,8 +65,15 @@ func TestDshCapabilities(t *testing.T) {
65
65
  if got := WorkspaceSupportOf("dsh"); got != "cwd" {
66
66
  t.Errorf("WorkspaceSupportOf(dsh) = %q want cwd(调用目录即 workspace 根)", got)
67
67
  }
68
+ // 附件:SDK 通道(默认)把图片作为原生 image block 发出;显式切 headless 后退化为
69
+ // 提示词路径(能力字段随实际通道走,见 AttachmentSupportOf)。
70
+ t.Setenv(DshProfileEnv, "")
71
+ if got := AttachmentSupportOf("dsh"); got != "sdk:content-blocks" {
72
+ t.Errorf("AttachmentSupportOf(dsh) = %q want sdk:content-blocks(SDK 通道原生收图)", got)
73
+ }
74
+ t.Setenv(DshProfileEnv, dshDefaultProfile)
68
75
  if got := AttachmentSupportOf("dsh"); got != "prompt" {
69
- t.Errorf("AttachmentSupportOf(dsh) = %q want prompt", got)
76
+ t.Errorf("headless 下 AttachmentSupportOf(dsh) = %q want prompt(无图片通道)", got)
70
77
  }
71
78
  if got := PermissionSupportOf("dsh"); got != "none" {
72
79
  t.Errorf("PermissionSupportOf(dsh) = %q want none(预设无法表达四档)", got)
@@ -197,29 +204,173 @@ func TestDshEmptyOutput(t *testing.T) {
197
204
  }
198
205
  }
199
206
 
200
- // 会话续接:headless 每次都是全新会话 → --session / -c 都明确报错(不静默开新会话)。
201
- func TestDshSessionUnsupported(t *testing.T) {
207
+ // 会话续接 = **真续接**:走 headless + --session-id,由 dsh 自己把上下文接回来。
208
+ // SDK 通道没有 resume 方法,所以续接轮**必须绕开它**(见 dsh_resume.go)。
209
+ func TestDshResumeUsesHeadlessSessionID(t *testing.T) {
202
210
  dir := t.TempDir()
203
- w, log := argsCaptureCLI(t, dir, "dsh", `echo '不该被执行'`)
211
+ w, log := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
212
+ // 默认 Profile 空 = 平时优先 SDK 通道,但续接轮不该走它。
204
213
  e := &DshEngine{BinPath: w.bin}
205
214
 
206
215
  if _, err := e.Complete(context.Background(), Request{
216
+ Messages: []Message{{Role: "user", Content: "接着问"}},
217
+ SessionID: "sess-1",
218
+ }); err != nil {
219
+ t.Fatalf("续接不该报错: %v", err)
220
+ }
221
+ args := readFileString(t, log)
222
+ for _, want := range []string{"--profile headless", "--session-id sess-1", "接着问"} {
223
+ if !strings.Contains(args, want) {
224
+ t.Errorf("续接轮参数里应有 %q:%q", want, args)
225
+ }
226
+ }
227
+ if strings.Contains(args, "--profile sdk") {
228
+ t.Errorf("续接轮不该走 SDK 通道(它没有 resume):%q", args)
229
+ }
230
+ }
231
+
232
+ // 续接轮不告警「--model 不生效」:那种轮的模型由**会话**决定。
233
+ func TestDshResumeSkipsModelWarning(t *testing.T) {
234
+ dir := t.TempDir()
235
+ w, _ := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
236
+ old := stderr
237
+ var buf strings.Builder
238
+ stderr = &buf
239
+ defer func() { stderr = old }()
240
+
241
+ if _, err := (&DshEngine{BinPath: w.bin}).Complete(context.Background(), Request{
207
242
  Messages: []Message{{Role: "user", Content: "hi"}},
208
243
  SessionID: "sess-1",
209
- }); err == nil {
210
- t.Error("--session 应报错")
211
- } else if !strings.Contains(err.Error(), "不支持会话续接") {
212
- t.Errorf("错误文案应说明不支持续接: %v", err)
244
+ Model: "modelverse/deepseek-v4.1-flash",
245
+ }); err != nil {
246
+ t.Fatalf("Complete: %v", err)
213
247
  }
214
- if _, err := e.Complete(context.Background(), Request{
215
- Messages: []Message{{Role: "user", Content: "hi"}},
216
- Continue: true,
217
- }); err == nil {
218
- t.Error("-c/--continue 应报错")
248
+ if strings.Contains(buf.String(), "不生效") {
249
+ t.Errorf("续接轮不该告警 -m 不生效:%q", buf.String())
250
+ }
251
+ }
252
+
253
+ // dsh 太老(headless 不认 --session-id)→ **明确报错**,而不是把 flag 拼进任务文本。
254
+ func TestDshResumeUnsupportedHintsUpgrade(t *testing.T) {
255
+ dir := t.TempDir()
256
+ bin := filepath.Join(dir, "dsh")
257
+ script := "#!/bin/sh\n" +
258
+ "case \"$*\" in *--help*) echo 'Usage: dsh --profile headless [--json] [task...]'; exit 0;; esac\n" +
259
+ "echo 'ok'\n"
260
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
261
+ t.Fatal(err)
219
262
  }
220
- // 两种情况下都不该真的启动 CLI。
221
- if _, err := os.Stat(log); err == nil {
222
- t.Error("续接被拒时不应启动 dsh 进程")
263
+ _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
264
+ Messages: []Message{{Role: "user", Content: "hi"}},
265
+ SessionID: "s1",
266
+ })
267
+ if err == nil || !strings.Contains(err.Error(), "不认 --session-id") {
268
+ t.Fatalf("应明确报「不支持 --session-id」, got %v", err)
269
+ }
270
+ }
271
+
272
+ // --json 事件流 → 与 SDK 通道同构的思考 / 工具 / 正文 / 收尾事件。
273
+ func TestDshStreamHeadlessJSONEvents(t *testing.T) {
274
+ dir := t.TempDir()
275
+ lines := []string{
276
+ `{"type":"session","sessionId":"sess-1","cwd":"/tmp"}`,
277
+ `{"type":"status","phase":"turn_start","turn":1}`,
278
+ `{"type":"thinking","text":"想了想"}`,
279
+ `{"type":"tool_call","callId":"c1","tool":"Bash","input":{"command":"ls"}}`,
280
+ `{"type":"tool_result","callId":"c1","status":"completed","result":"a.txt"}`,
281
+ `{"type":"text","text":"答案是 42"}`,
282
+ `{"type":"status","phase":"turn_end","turn":1,"reason":{"kind":"completed"}}`,
283
+ `{"type":"final","text":"答案是 42"}`,
284
+ }
285
+ body := "case \"$*\" in *--help*) echo 'Usage: dsh --profile headless [--json] [--session-id <id>] [task...]'; exit 0;; esac\n"
286
+ for _, l := range lines {
287
+ body += "printf '%s\\n' '" + l + "'\n"
288
+ }
289
+ w, log := argsCaptureCLI(t, dir, "dsh", body)
290
+
291
+ var got []StreamEvent
292
+ var sid string
293
+ res, err := (&DshEngine{BinPath: w.bin}).Stream(context.Background(), Request{
294
+ Messages: []Message{{Role: "user", Content: "问"}},
295
+ SessionID: "sess-1",
296
+ OnSessionID: func(id string) { sid = id },
297
+ }, func(ev StreamEvent) { got = append(got, ev) })
298
+ if err != nil {
299
+ t.Fatalf("Stream: %v", err)
300
+ }
301
+ if args := readFileString(t, log); !strings.Contains(args, "--json") {
302
+ t.Errorf("headless 流式应带 --json:%q", args)
303
+ }
304
+ if res.Response.Text != "答案是 42" {
305
+ t.Errorf("Text = %q want 答案是 42", res.Response.Text)
306
+ }
307
+ if res.Response.SessionID != "sess-1" {
308
+ t.Errorf("SessionID = %q want sess-1(续接轮原样回报)", res.Response.SessionID)
309
+ }
310
+ if sid != "sess-1" {
311
+ t.Errorf("OnSessionID = %q want sess-1", sid)
312
+ }
313
+ if res.Thinking != "想了想" {
314
+ t.Errorf("Thinking = %q want 想了想", res.Thinking)
315
+ }
316
+ if len(res.Tools) != 1 || res.Tools[0].Name != "Bash" || res.Tools[0].Result != "a.txt" {
317
+ t.Errorf("Tools = %+v", res.Tools)
318
+ }
319
+ var kinds []StreamEventKind
320
+ for _, ev := range got {
321
+ kinds = append(kinds, ev.Kind)
322
+ }
323
+ want := []StreamEventKind{KindThinking, KindToolUse, KindToolResult, KindText, KindTurnEnd}
324
+ if fmt.Sprint(kinds) != fmt.Sprint(want) {
325
+ t.Errorf("事件序列 = %v want %v", kinds, want)
326
+ }
327
+ }
328
+
329
+ // 参数形态:启动器 flag 在前,--session-id / --json 在 app 参数区(headless 自己的 flag)。
330
+ func TestDshHeadlessArgs(t *testing.T) {
331
+ cases := []struct {
332
+ name string
333
+ sessionID string
334
+ json bool
335
+ want []string
336
+ }{
337
+ {"纯 headless", "", false, []string{"--profile", "headless", "任务"}},
338
+ {"续接", "s1", false, []string{"--profile", "headless", "--session-id", "s1", "任务"}},
339
+ {"事件流", "", true, []string{"--profile", "headless", "--json", "任务"}},
340
+ {"续接 + 事件流", "s1", true, []string{"--profile", "headless", "--session-id", "s1", "--json", "任务"}},
341
+ }
342
+ for _, c := range cases {
343
+ got := dshHeadlessArgs("headless", c.sessionID, "任务", c.json)
344
+ if fmt.Sprint(got) != fmt.Sprint(c.want) {
345
+ t.Errorf("%s: %v want %v", c.name, got, c.want)
346
+ }
347
+ }
348
+ }
349
+
350
+ // -c/--continue:按会话登记表找**同一 workspace** 下最近一条 dsh 会话(其它引擎不算)。
351
+ func TestDshLatestSessionIDPrefersWorkspace(t *testing.T) {
352
+ sess := t.TempDir()
353
+ writeRun := func(name, engine, id, ws, at string) {
354
+ t.Helper()
355
+ rec := `{"run_id":"` + name + `","session_id":"` + id + `","engine":"` + engine +
356
+ `","workspace":"` + ws + `","updated_at":"` + at + `"}`
357
+ if err := os.WriteFile(filepath.Join(sess, name+".json"), []byte(rec), 0o600); err != nil {
358
+ t.Fatal(err)
359
+ }
360
+ }
361
+ writeRun("run-a", "dsh", "sid-a", "/proj/a", "2026-10-09T12:00:00+08:00")
362
+ writeRun("run-b", "dsh", "sid-b", "/proj/b", "2026-10-09T13:00:00+08:00") // 更晚,别的 workspace
363
+ writeRun("run-c", "claude", "sid-c", "/proj/a", "2026-10-09T14:00:00+08:00") // 更晚,但不是 dsh
364
+ t.Setenv(dshSessionsEnv, sess)
365
+
366
+ if got := dshLatestSessionID("/proj/a"); got != "sid-a" {
367
+ t.Errorf("dshLatestSessionID(/proj/a) = %q want sid-a(同 workspace 的最近一条)", got)
368
+ }
369
+ if got := dshLatestSessionID(""); got != "sid-b" {
370
+ t.Errorf("dshLatestSessionID(\"\") = %q want sid-b(不限 workspace → 最新 dsh)", got)
371
+ }
372
+ if got := dshLatestSessionID("/proj/none"); got != "sid-b" {
373
+ t.Errorf("dshLatestSessionID(/proj/none) = %q want sid-b(无同 workspace 记录时兜底最新)", got)
223
374
  }
224
375
  }
225
376
 
@@ -551,6 +702,195 @@ func TestDshConfiguredModel(t *testing.T) {
551
702
  }
552
703
  }
553
704
 
705
+ // ── dsh 0.1.7+:provider 配置搬到了 profile 的 cordis.patch.yml ──────────
706
+ //
707
+ // 背景(2026-10-09 实测):dsh 0.1.7 重构 settings 后,首启会把 $DSH_HOME/settings.yaml
708
+ // 改名为 settings.yaml.imported,providers 改由各 profile 的 cordis.patch.yml 承载。
709
+ // magic-agent 若只认 settings.yaml,--engines 的 dsh 模型清单就会整段消失。
710
+
711
+ // dshPatchFixture profile patch 形态的示例配置(与 dsh 0.1.7 实际写出的形状一致)。
712
+ const dshPatchFixture = `# Your patch layer for this dsh profile, applied after every bundle layer:
713
+ # a top-level YAML array of loader patch entries.
714
+ - id: llm-pi-ai
715
+ config:
716
+ providers:
717
+ modelverse:
718
+ displayName: Modelverse
719
+ api: openai-completions
720
+ baseURL: https://api.modelverse.cn/v1
721
+ apiKeyEnv: MODELVERSE_API_KEY
722
+ models:
723
+ - id: deepseek-v4.1-flash
724
+ name: DeepSeek V4.1 Flash
725
+ - id: glm-5.3
726
+ name: GLM-5.3
727
+ - id: agent-default-model
728
+ config:
729
+ provider: modelverse
730
+ model: deepseek-v4.1-flash
731
+ `
732
+
733
+ // writeDshPatch 把 profile patch 写进 $DSH_HOME/profiles/<profile>/cordis.patch.yml,
734
+ // 返回 $DSH_HOME(调用方自行 t.Setenv)。
735
+ func writeDshPatch(t *testing.T, profile, body string) string {
736
+ t.Helper()
737
+ home := t.TempDir()
738
+ dir := filepath.Join(home, "profiles", profile)
739
+ if err := os.MkdirAll(dir, 0o755); err != nil {
740
+ t.Fatal(err)
741
+ }
742
+ if err := os.WriteFile(filepath.Join(dir, "cordis.patch.yml"), []byte(body), 0o644); err != nil {
743
+ t.Fatal(err)
744
+ }
745
+ return home
746
+ }
747
+
748
+ // 归一化:patch 条目形态 → settings.yaml 顶层键形态,复用同一套解析器。
749
+ func TestDshPatchAsLegacy(t *testing.T) {
750
+ cases := []struct {
751
+ name string
752
+ patch string
753
+ models []string
754
+ provider string
755
+ model string
756
+ }{
757
+ {
758
+ name: "标准两个条目",
759
+ patch: dshPatchFixture,
760
+ models: []string{"modelverse/deepseek-v4.1-flash", "modelverse/glm-5.3"},
761
+ provider: "modelverse",
762
+ model: "deepseek-v4.1-flash",
763
+ },
764
+ {
765
+ name: "空 patch([])",
766
+ patch: "# 注释\n[]\n",
767
+ models: nil,
768
+ },
769
+ {
770
+ name: "没有 config 的条目跳过",
771
+ patch: "- id: hmr\n disabled: true\n- id: llm-pi-ai\n config:\n providers:\n gw:\n models:\n - id: a\n",
772
+ models: []string{"gw/a"},
773
+ },
774
+ {
775
+ // 回归点:models 里缩进更深的 "- id:" 不能被当成新条目边界,
776
+ // 否则第一个模型之后的内容会被整段丢掉。
777
+ name: "条目内嵌套 - id 不作为边界",
778
+ patch: "- id: llm-pi-ai\n config:\n providers:\n gw:\n models:\n - id: a\n - id: b\n",
779
+ models: []string{"gw/a", "gw/b"},
780
+ },
781
+ {
782
+ // config 之后、缩进回到同层的键(disabled)不属于 config 内容。
783
+ name: "config 之后的同层键不搬",
784
+ patch: "- id: llm-pi-ai\n config:\n providers:\n gw:\n models:\n - id: a\n disabled: true\n",
785
+ models: []string{"gw/a"},
786
+ },
787
+ {
788
+ name: "无 providers 时回落 agent-default-model",
789
+ patch: "- id: agent-default-model\n config:\n provider: gw\n model: only\n",
790
+ models: []string{"gw/only"},
791
+ provider: "gw",
792
+ model: "only",
793
+ },
794
+ }
795
+ for _, c := range cases {
796
+ legacy := dshPatchAsLegacy(c.patch)
797
+ if got := parseDshModelRoutes(legacy); !equalStrings(got, c.models) {
798
+ t.Errorf("%s: models = %v want %v(归一化文本:\n%s)", c.name, got, c.models, legacy)
799
+ }
800
+ if p, m := parseDshDefaultModelRoute(legacy); p != c.provider || m != c.model {
801
+ t.Errorf("%s: route = (%q,%q) want (%q,%q)", c.name, p, m, c.provider, c.model)
802
+ }
803
+ }
804
+ }
805
+
806
+ // dsh 0.1.7+:从 profile 的 cordis.patch.yml 读出模型清单。
807
+ func TestDshListModelsFromProfilePatch(t *testing.T) {
808
+ t.Setenv("MAGIC_AGENT_DSH_PROFILE", "")
809
+ home := writeDshPatch(t, "sdk", dshPatchFixture)
810
+ t.Setenv("DSH_HOME", home)
811
+
812
+ got, err := (&DshEngine{}).ListModels(context.Background())
813
+ if err != nil {
814
+ t.Fatalf("ListModels: %v", err)
815
+ }
816
+ want := []string{"modelverse/deepseek-v4.1-flash", "modelverse/glm-5.3"}
817
+ if !equalStrings(got, want) {
818
+ t.Errorf("ListModels() = %v\n want %v", got, want)
819
+ }
820
+ }
821
+
822
+ // 两条通道(sdk + headless 回退)的 patch 取并集:magic-agent 两条都会用到。
823
+ func TestDshListModelsUnionAcrossProfiles(t *testing.T) {
824
+ t.Setenv("MAGIC_AGENT_DSH_PROFILE", "")
825
+ home := writeDshPatch(t, "sdk",
826
+ "- id: llm-pi-ai\n config:\n providers:\n gw1:\n models:\n - id: a\n")
827
+ headlessDir := filepath.Join(home, "profiles", "headless")
828
+ if err := os.MkdirAll(headlessDir, 0o755); err != nil {
829
+ t.Fatal(err)
830
+ }
831
+ if err := os.WriteFile(filepath.Join(headlessDir, "cordis.patch.yml"),
832
+ []byte("- id: llm-pi-ai\n config:\n providers:\n gw2:\n models:\n - id: b\n"), 0o644); err != nil {
833
+ t.Fatal(err)
834
+ }
835
+ t.Setenv("DSH_HOME", home)
836
+
837
+ got, err := (&DshEngine{}).ListModels(context.Background())
838
+ if err != nil {
839
+ t.Fatalf("ListModels: %v", err)
840
+ }
841
+ want := []string{"gw1/a", "gw2/b"}
842
+ if !equalStrings(got, want) {
843
+ t.Errorf("ListModels() = %v\n want %v", got, want)
844
+ }
845
+ }
846
+
847
+ // MAGIC_AGENT_DSH_PROFILE 指定的 profile 也要被读取(不只看默认的 sdk)。
848
+ func TestDshListModelsHonorsProfileEnv(t *testing.T) {
849
+ prof := "rescue"
850
+ home := writeDshPatch(t, prof,
851
+ "- id: llm-pi-ai\n config:\n providers:\n gw:\n models:\n - id: a\n")
852
+ t.Setenv("DSH_HOME", home)
853
+ t.Setenv(DshProfileEnv, prof)
854
+
855
+ got, err := (&DshEngine{}).ListModels(context.Background())
856
+ if err != nil {
857
+ t.Fatalf("ListModels: %v", err)
858
+ }
859
+ if want := []string{"gw/a"}; !equalStrings(got, want) {
860
+ t.Errorf("ListModels() = %v want %v", got, want)
861
+ }
862
+ }
863
+
864
+ // settings.yaml.imported 是 dsh 迁移留下的**残留**,dsh 已不再读它 ——
865
+ // 采信它会让用户选到运行时拒绝的 route,所以清单里绝不能出现它的模型。
866
+ func TestDshListModelsIgnoresImportedResidue(t *testing.T) {
867
+ home := t.TempDir()
868
+ t.Setenv("DSH_HOME", home)
869
+ legacy := "agent-default-model:\n provider: modelverse\n model: deepseek-v4.1-flash\n" +
870
+ "llm-pi-ai:\n providers:\n modelverse:\n models:\n - id: deepseek-v4.1-flash\n"
871
+ if err := os.WriteFile(filepath.Join(home, "settings.yaml.imported"), []byte(legacy), 0o600); err != nil {
872
+ t.Fatal(err)
873
+ }
874
+ _, err := (&DshEngine{}).ListModels(context.Background())
875
+ if !errors.Is(err, ErrNoModelSource) {
876
+ t.Fatalf("want ErrNoModelSource(不该采信 .imported), got %v", err)
877
+ }
878
+ }
879
+
880
+ // DshConfiguredModel / DshConfiguredRoute:同样认 profile patch 形态。
881
+ func TestDshConfiguredDefaultFromProfilePatch(t *testing.T) {
882
+ t.Setenv("MAGIC_AGENT_DSH_PROFILE", "")
883
+ home := writeDshPatch(t, "sdk", dshPatchFixture)
884
+ t.Setenv("DSH_HOME", home)
885
+
886
+ if got := DshConfiguredModel(); got != "deepseek-v4.1-flash" {
887
+ t.Errorf("DshConfiguredModel() = %q want deepseek-v4.1-flash", got)
888
+ }
889
+ if p, m := DshConfiguredRoute(); p != "modelverse" || m != "deepseek-v4.1-flash" {
890
+ t.Errorf("DshConfiguredRoute() = (%q,%q) want (modelverse,deepseek-v4.1-flash)", p, m)
891
+ }
892
+ }
893
+
554
894
  // Response.Model:配置里读不到时回落 "config-default"(标签,不是模型名)。
555
895
  func TestDshResponseModelFallback(t *testing.T) {
556
896
  t.Setenv("DSH_HOME", t.TempDir())
@@ -580,10 +920,10 @@ func TestDshHome(t *testing.T) {
580
920
  }
581
921
  }
582
922
 
583
- // 默认超时要盖住「dsh 自带工具循环」的长任务(不短于 5 分钟)。
923
+ // 默认超时要盖住「dsh 自带工具循环」的长任务(不短于 1 小时)。
584
924
  func TestDefaultDshTimeoutCoversAgentTasks(t *testing.T) {
585
- if DefaultDshTimeout < 5*time.Minute {
586
- t.Errorf("DefaultDshTimeout = %v,agent 任务常常分钟级,应留足余量", DefaultDshTimeout)
925
+ if DefaultDshTimeout < time.Hour {
926
+ t.Errorf("DefaultDshTimeout = %v,agent 任务常常分钟级,应留足余量(≥ 1 小时)", DefaultDshTimeout)
587
927
  }
588
928
  // Runner 按引擎类型取默认超时。
589
929
  r := &Runner{Engine: &DshEngine{}}
@@ -594,14 +934,17 @@ func TestDefaultDshTimeoutCoversAgentTasks(t *testing.T) {
594
934
 
595
935
  // ── 流式(--stream)────────────────────────────────────────────
596
936
  //
597
- // dsh 的增量在 stderr("dsh: reasoning:" 起头),正文一次性走 stdout。
598
- // 实测依据:2026-09-21,dsh 0.1.5-rc.2(见 dsh.go 的 Stream 注释)。
937
+ // 这两个用例覆盖的是**老版 dsh**(headless 没有 --json):增量在 stderr
938
+ // ("dsh: reasoning:" 起头),正文一次性走 stdout。实测依据:2026-09-21,dsh 0.1.5-rc.2。
939
+ // 假 CLI 因此要在 `--help` 里如实报出「没有 --json」—— 能力探测据此才选老版解析路径
940
+ // (新版的 --json 事件流由 TestDshStreamHeadlessJSONEvents 覆盖)。
599
941
 
600
- // dshStreamStub 造一个假 dsh:推理分批写 stderr,正文最后写 stdout。
942
+ // dshStreamStub 造一个假 dsh:推理分批写 stderr,正文最后写 stdout;--help 报老版形态。
601
943
  func dshStreamStub(t *testing.T, dir string) (bin string) {
602
944
  t.Helper()
603
945
  bin = filepath.Join(dir, "dsh")
604
946
  script := `#!/bin/sh
947
+ case "$*" in *--help*) echo 'Usage: dsh --profile headless [task...]'; exit 0;; esac
605
948
  echo "dsh: reasoning:" >&2
606
949
  echo "第一步:读题" >&2
607
950
  sleep 0.05
@@ -684,6 +1027,7 @@ func TestDshStreamIgnoresNonReasoningStderr(t *testing.T) {
684
1027
  dir := t.TempDir()
685
1028
  bin := filepath.Join(dir, "dsh")
686
1029
  script := `#!/bin/sh
1030
+ case "$*" in *--help*) echo 'Usage: dsh --profile headless [task...]'; exit 0;; esac
687
1031
  echo "openviking: 插件已注册" >&2
688
1032
  echo "dsh: reasoning:" >&2
689
1033
  echo "真正的推理" >&2
@@ -709,19 +1053,41 @@ echo "正文"
709
1053
  }
710
1054
  }
711
1055
 
712
- // 流式路径同样拒续接(与 Complete 同一契约),且不启动进程。
713
- func TestDshStreamRejectsSession(t *testing.T) {
1056
+ // 流式路径同样走回放式续接(与 Complete 同一契约)。
1057
+ // 流式续接轮:同样走 headless + --session-id(不是 SDK,也不把历史塞进 prompt)。
1058
+ func TestDshStreamResumesViaSessionID(t *testing.T) {
714
1059
  dir := t.TempDir()
715
- w, log := argsCaptureCLI(t, dir, "dsh", `echo '不该被执行'`)
716
- _, err := (&DshEngine{BinPath: w.bin}).Stream(context.Background(), Request{
717
- Messages: []Message{{Role: "user", Content: "hi"}},
1060
+ w, log := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
1061
+
1062
+ var got []StreamEvent
1063
+ if _, err := (&DshEngine{BinPath: w.bin}).Stream(context.Background(), Request{
1064
+ Messages: []Message{{Role: "user", Content: "接着说"}},
718
1065
  SessionID: "s1",
719
- }, nil)
720
- if err == nil || !strings.Contains(err.Error(), "不支持会话续接") {
721
- t.Fatalf("流式路径也应拒续接, got %v", err)
1066
+ }, func(ev StreamEvent) { got = append(got, ev) }); err != nil {
1067
+ t.Fatalf("流式续接不该报错: %v", err)
722
1068
  }
723
- if _, serr := os.Stat(log); serr == nil {
724
- t.Error("拒续接时不应启动 dsh 进程")
1069
+ args := readFileString(t, log)
1070
+ for _, want := range []string{"--profile headless", "--session-id s1", "接着说"} {
1071
+ if !strings.Contains(args, want) {
1072
+ t.Errorf("流式续接参数里应有 %q:%q", want, args)
1073
+ }
1074
+ }
1075
+ if strings.Contains(args, "--profile sdk") {
1076
+ t.Errorf("流式续接不该走 SDK 通道:%q", args)
1077
+ }
1078
+ // 收尾事件仍要发(一次调用 = 一轮),且必须带会话锚点 ——
1079
+ // 客户端只从 turn_end / turn_failed 学会话 id(少了它,下一步追问会发成新会话)。
1080
+ var sawEnd bool
1081
+ for _, ev := range got {
1082
+ if ev.Kind == KindTurnEnd {
1083
+ sawEnd = true
1084
+ if ev.SessionID != "s1" {
1085
+ t.Errorf("turn_end 应带会话锚点: got %q want %q", ev.SessionID, "s1")
1086
+ }
1087
+ }
1088
+ }
1089
+ if !sawEnd {
1090
+ t.Errorf("该补一条 turn_end,实得 %v", got)
725
1091
  }
726
1092
  }
727
1093
 
@@ -741,19 +1107,26 @@ func TestDshStreamNonZeroExit(t *testing.T) {
741
1107
  }
742
1108
  }
743
1109
 
744
- // 参数矩阵:MaxTokens / Temperature / Tools / Timeout 在 dsh 上不产生任何 flag。
1110
+ // 参数矩阵:MaxTokens / Temperature / Tools / Timeout 在 dsh 上不产生任何 flag
1111
+ // (Request 只在 headless 上有 prompt + 可选的 --session-id / --json)。
745
1112
  func TestDshIgnoredParamsProduceNoFlags(t *testing.T) {
746
1113
  t.Setenv(DshProfileEnv, "") // 隔离外部 profile 覆盖
747
1114
  e := &DshEngine{}
748
1115
  temp := 0.7
749
- args := e.buildArgs(Request{
1116
+ req := Request{
750
1117
  MaxTokens: 4096,
751
1118
  Temperature: &temp,
752
1119
  Tools: ToolsOn,
753
1120
  Timeout: 3 * time.Minute,
754
- }, "【用户】\nhi")
1121
+ }
1122
+ // 显式读一遍这些字段,证明它们确实没进参数(旧实现里是 `_ = req.X` 那几行)。
1123
+ _ = req.MaxTokens
1124
+ _ = req.Temperature
1125
+ _ = req.Tools
1126
+ _ = req.Timeout
1127
+ args := dshHeadlessArgs(e.profile(), "", "【用户】\nhi", false)
755
1128
  want := []string{"--profile", "headless", "【用户】\nhi"}
756
1129
  if fmt.Sprint(args) != fmt.Sprint(want) {
757
- t.Errorf("buildArgs = %v want %v", args, want)
1130
+ t.Errorf("headless args = %v want %v", args, want)
758
1131
  }
759
1132
  }
@@ -40,9 +40,12 @@ type Message struct {
40
40
 
41
41
  // ToolsMode 工具开关模式。
42
42
  //
43
- // ToolsOff 禁用全部工具(默认):纯 chat 一次成型,输出可解析。
43
+ // ToolsOff 禁用全部工具:纯 chat 一次成型,输出可解析。
44
44
  // ToolsOn 引擎默认工具集 + 权限旁路:真 agent 模式,模型可执行
45
45
  // 工具调用(读文件、跑命令等),代价是输出可能是工具痕迹。
46
+ // ⚠️ 2026-10-01:默认档从 Off 改成 On —— 用户报障「claude 只会
47
+ // 闲聊、不读文件、不能用工具」就是因为这个默认。magic-agent
48
+ // 装在本机就是要当 agent 跑,不是 chat 套壳。
46
49
  // ToolsAllowlist 白名单:仅允许列出的工具 + 权限旁路。
47
50
  //
48
51
  // 判定方法一律导出:Engine 实现与本包外的调用方(如 CLI 层测试)
@@ -93,7 +96,7 @@ type Request struct {
93
96
  //(claude/codebuddy用 --append-system-prompt,trae 拼进 prompt 头)。
94
97
  SystemPrompt string
95
98
 
96
- // Tools 工具开关模式(默认 ToolsOff)。
99
+ // Tools 工具开关模式(默认 ToolsOn —— 2026-10-01 实测用户期望)。
97
100
  Tools ToolsMode
98
101
 
99
102
  // Permission 四档权限档位(默认 DefaultPermissionTier = full,保持既有行为)。
@@ -141,6 +144,16 @@ type Request struct {
141
144
  // 忽略(不上报错误)。
142
145
  Temperature *float64
143
146
 
147
+ // Reasoning 推理/思考档位。空串 = 引擎默认;off/none/disabled = 不传参数。
148
+ // 取值 minimal | low | medium | high | xhigh | max。
149
+ //
150
+ // 各引擎映射(只对原生支持的引擎生效,其余静默忽略):
151
+ // claude --effort <level>(无 minimal,降级为 low)
152
+ // codebuddy --effort <level>(codebuddy-ai 同族同映射)
153
+ // codex -c model_reasoning_effort=<level>(exec 与 exec resume 均可用)
154
+ // trae/llm/openclaw/dsh/arkclaw 无对应参数。
155
+ Reasoning string
156
+
144
157
  // JSONSchema 是可选的结构化输出约束。
145
158
  //
146
159
  // 各引擎行为:
@@ -426,18 +439,25 @@ func agentEnginesFrom(agents []config.AgentConfig, existing []Engine) []Engine {
426
439
  return out
427
440
  }
428
441
 
429
- // toolsOrDefault nil ToolsMode 视为 ToolsOff。
442
+ // toolsOrDefault nil ToolsMode 视为 ToolsOn。
443
+ //
444
+ // ⚠️ 2026-10-01 反转:用户报障「claude 只聊天、不读文件、不能用工具」,
445
+ //
446
+ // 根因就是这里把 nil 兜底成 Off。magic-agent 的定位是本机 agent,不是 chat
447
+ // 套壳,nil(=调用方不显式声明工具)按"未指定 = 想要工具"处理才符合直觉。
448
+ // 想禁用工具的调用方传 `agent.ToolsOff` 即可(API 与 CLI 都已暴露)。
449
+ //
430
450
  // 返回接口(而非具体实现):判定一律走 ToolsMode 的导出方法。
431
451
  func toolsOrDefault(t ToolsMode) ToolsMode {
432
452
  if t == nil {
433
- return ToolsOff
453
+ return ToolsOn
434
454
  }
435
455
  return t
436
456
  }
437
457
 
438
- // toolsIsOff 是否「禁用工具」模式(nil 视为 off)。
458
+ // toolsIsOff 是否「禁用工具」模式(nil 视为 on,详见 toolsOrDefault 注释)。
439
459
  // 各引擎用它决定是否注入 noToolSuffix —— 该后缀明文禁止工具调用,
440
- // 只能在 off 模式出现,否则会和 on / 白名单模式互相打架。
460
+ // 只能在显式 off 模式出现,否则会和 on / 白名单模式互相打架。
441
461
  func toolsIsOff(req Request) bool {
442
462
  return toolsOrDefault(req.Tools).IsOff()
443
463
  }