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
@@ -43,6 +43,68 @@ func arkClawWorkingFrame(contextID string) string {
43
43
  `"history":[]}}`, contextID)
44
44
  }
45
45
 
46
+ // arkClawSplit 把事件按 kind 拆成「正文增量」与「轮次边界」两组。
47
+ //
48
+ // 为什么测试不能直接 len(events):completed 帧收尾会多发一条 KindTurnEnd
49
+ // (带权威全文,status=final),它**不是**又一段正文。收齐所有 ev.Text 再拼会
50
+ // 把 final 全文重复算进增量(与 codebuddy-gateway 改测试时踩的同一个坑)。
51
+ // 同理,turn_end 不能触发 AppendSupportOf 的 keep-alive 收工 —— arkclaw 不在名单里。
52
+ func arkClawSplit(events *[]StreamEvent) (texts []StreamEvent, ends []StreamEvent) {
53
+ for _, ev := range *events {
54
+ switch ev.Kind {
55
+ case KindText:
56
+ texts = append(texts, ev)
57
+ case KindTurnEnd:
58
+ ends = append(ends, ev)
59
+ }
60
+ }
61
+ return texts, ends
62
+ }
63
+
64
+ // arkClawSplitFailed 与 arkClawSplit 同构,但收的是**异常终态** turn_failed。
65
+ // 单独一个入口是为了让「截断/失败」用例不必记住第三种 kind 的存在 ——
66
+ // 终态只有两种(turn_end 成功 / turn_failed 异常),正文永远只是 turn。
67
+ func arkClawSplitFailed(events *[]StreamEvent) (texts []StreamEvent, failed []StreamEvent) {
68
+ for _, ev := range *events {
69
+ switch ev.Kind {
70
+ case KindText:
71
+ texts = append(texts, ev)
72
+ case KindTurnFailed:
73
+ failed = append(failed, ev)
74
+ }
75
+ }
76
+ return texts, failed
77
+ }
78
+
79
+ // assertArkClawFinal 断言 turn_end 承担了 final:挂在正文 item 上、revision 更高、
80
+ // 正文是权威全文。与 gateway / claude 同一约定。
81
+ func assertArkClawFinal(t *testing.T, texts, ends []StreamEvent, wantFinal string) {
82
+ t.Helper()
83
+ if len(texts) == 0 {
84
+ t.Fatal("缺少正文事件")
85
+ }
86
+ if len(ends) != 1 {
87
+ t.Fatalf("turn_end 事件数 = %d,want 1", len(ends))
88
+ }
89
+ end := ends[0]
90
+ if end.Status != StatusFinal {
91
+ t.Errorf("turn_end.Status = %q want %q", end.Status, StatusFinal)
92
+ }
93
+ if end.Text != wantFinal {
94
+ t.Errorf("turn_end.Text = %q want 权威全文 %q", end.Text, wantFinal)
95
+ }
96
+ if end.ItemID != texts[0].ItemID {
97
+ t.Errorf("turn_end 应挂在正文 item 上:%q vs %q", end.ItemID, texts[0].ItemID)
98
+ }
99
+ if end.ItemRevision <= texts[len(texts)-1].ItemRevision {
100
+ t.Errorf("turn_end revision 必须更高:正文末帧=%d turn_end=%d",
101
+ texts[len(texts)-1].ItemRevision, end.ItemRevision)
102
+ }
103
+ if end.ItemID == "" {
104
+ t.Error("turn_end 缺 item_id(状态收敛字段为空)")
105
+ }
106
+ }
107
+
46
108
  // arkClawArtifactFrame 组一个把正文挂在 artifacts 上的帧(网关将来吐增量时的形态)。
47
109
  func arkClawArtifactFrame(state, contextID, text string) string {
48
110
  return fmt.Sprintf(`{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1",`+
@@ -102,13 +164,16 @@ func TestArkClawStreamSSECompleted(t *testing.T) {
102
164
  t.Errorf("method = %q want message/stream", sent.Method)
103
165
  }
104
166
 
105
- // 事件:working 帧**不发事件**(网关没有增量可发),completed 帧发一条正文增量。
106
- if len(*events) != 1 {
107
- t.Fatalf("事件数 = %d want 1: %+v", len(*events), *events)
167
+ // 事件:working 帧**不发事件**(网关没有增量可发),completed 帧发一条正文增量,
168
+ // 收尾多发一条 turn_end(final,不是又一段正文 —— 见 arkClawSplit)。
169
+ texts, ends := arkClawSplit(events)
170
+ if len(texts) != 1 {
171
+ t.Fatalf("正文增量数 = %d want 1: %+v", len(texts), *events)
108
172
  }
109
- if ev := (*events)[0]; ev.Kind != KindText || ev.Text != text {
173
+ if ev := texts[0]; ev.Kind != KindText || ev.Text != text {
110
174
  t.Errorf("事件 = %+v want KindText/%q", ev, text)
111
175
  }
176
+ assertArkClawFinal(t, texts, ends, text)
112
177
 
113
178
  if res.Text != text {
114
179
  t.Errorf("Text = %q", res.Text)
@@ -136,8 +201,8 @@ func TestArkClawStreamSkipsHeartbeatAndNonDataLines(t *testing.T) {
136
201
  if err != nil {
137
202
  t.Fatalf("Stream: %v", err)
138
203
  }
139
- if len(*events) != 1 || (*events)[0].Text != "正文" {
140
- t.Errorf("事件 = %+v want 单条正文", *events)
204
+ if texts, _ := arkClawSplit(events); len(texts) != 1 || texts[0].Text != "正文" {
205
+ t.Errorf("正文增量 = %+v want 单条正文", texts)
141
206
  }
142
207
  if res.Text != "正文" {
143
208
  t.Errorf("Text = %q", res.Text)
@@ -160,8 +225,10 @@ func TestArkClawStreamDedupWhenArtifactsRepeat(t *testing.T) {
160
225
  if err != nil {
161
226
  t.Fatalf("Stream: %v", err)
162
227
  }
163
- if len(*events) != 1 {
164
- t.Fatalf("事件数 = %d want 1(同一份正文不得发两次): %+v", len(*events), *events)
228
+ // 正文只发一次(status.message 与 artifacts 同文,去重后差量为空);
229
+ // 收尾的 turn_end 不算正文,见 arkClawSplit。
230
+ if texts, _ := arkClawSplit(events); len(texts) != 1 {
231
+ t.Fatalf("正文增量数 = %d want 1(同一份正文不得发两次): %+v", len(texts), *events)
165
232
  }
166
233
  if res.Text != "同一份正文" {
167
234
  t.Errorf("Text = %q", res.Text)
@@ -182,12 +249,16 @@ func TestArkClawStreamForwardsArtifactIncrements(t *testing.T) {
182
249
  if err != nil {
183
250
  t.Fatalf("Stream: %v", err)
184
251
  }
185
- if len(*events) != 2 {
186
- t.Fatalf("事件数 = %d want 2: %+v", len(*events), *events)
252
+ // 正文两条增量(第一段 / 第二段),completed 帧的整段前缀去重后无新增。
253
+ // turn_end 单独承担轮次边界,不计入增量。
254
+ texts, ends := arkClawSplit(events)
255
+ if len(texts) != 2 {
256
+ t.Fatalf("正文增量数 = %d want 2: %+v", len(texts), *events)
187
257
  }
188
- if (*events)[0].Text != "第一段" || (*events)[1].Text != "第二段" {
189
- t.Errorf("增量 = %q / %q", (*events)[0].Text, (*events)[1].Text)
258
+ if texts[0].Text != "第一段" || texts[1].Text != "第二段" {
259
+ t.Errorf("增量 = %q / %q", texts[0].Text, texts[1].Text)
190
260
  }
261
+ assertArkClawFinal(t, texts, ends, "第一段第二段")
191
262
  if res.Text != "第一段第二段" {
192
263
  t.Errorf("Text = %q", res.Text)
193
264
  }
@@ -256,9 +327,13 @@ func TestArkClawStreamJSONSchemaPostProcess(t *testing.T) {
256
327
  if err := json.Unmarshal([]byte(res.Text), &out); err != nil {
257
328
  t.Fatalf("Text 不是纯 JSON: %v (%q)", err, res.Text)
258
329
  }
259
- if len(*events) != 1 || (*events)[0].Text != res.Text {
330
+ // 增量正文(已 JSONSchema 抽取)必须与收尾正文逐字节相同。
331
+ // 注意别把 turn_end 的权威全文当成第二段增量算进来。
332
+ texts, ends := arkClawSplit(events)
333
+ if len(texts) != 1 || texts[0].Text != res.Text {
260
334
  t.Errorf("增量应与收尾正文一致: events=%+v text=%q", *events, res.Text)
261
335
  }
336
+ assertArkClawFinal(t, texts, ends, res.Text)
262
337
  }
263
338
 
264
339
  // ---------- 错误路径 ----------
@@ -395,8 +470,15 @@ func TestArkClawStreamNonSSEFallback(t *testing.T) {
395
470
  if res.Text != "一次性正文" || res.SessionID != "ctx-f1" {
396
471
  t.Errorf("res = %+v", res.Response)
397
472
  }
398
- if len(*events) != 1 || (*events)[0].Text != "一次性正文" {
399
- t.Errorf("事件 = %+v want 单条正文", *events)
473
+ // 回退路径同样有状态收敛:整段正文一条 text + 一条 turn_end(final),
474
+ // 且 item id 取 task id(与 SSE 路径同源,见 arkClawTaskID 的注释)。
475
+ texts, ends := arkClawSplit(events)
476
+ if len(texts) != 1 || texts[0].Text != "一次性正文" {
477
+ t.Errorf("正文增量 = %+v want 单条正文", texts)
478
+ }
479
+ assertArkClawFinal(t, texts, ends, "一次性正文")
480
+ if !strings.HasPrefix(texts[0].ItemID, "arkclaw_msg_") || strings.Contains(texts[0].ItemID, "local_") {
481
+ t.Errorf("回退路径应沿用原生 task id 而非降级序号: %q", texts[0].ItemID)
400
482
  }
401
483
  if !strings.Contains(buf.String(), "SSE") {
402
484
  t.Errorf("回退应在 stderr 说明原因: %q", buf.String())
@@ -659,10 +659,10 @@ func TestArkClawNoModelNoWarning(t *testing.T) {
659
659
 
660
660
  func TestDefaultArkClawTimeoutCoversObservedLatency(t *testing.T) {
661
661
  // 实测短问答 8~23s,但**带工具循环的长任务**要 5 分钟(2026-09-24:「生成周报」304s 才回,
662
- // 期间每 15s 一个 working 心跳)。默认值必须留足余量,且**不得短于 CLI 的 -t 默认(600s)**
662
+ // 期间每 15s 一个 working 心跳)。默认值必须留足余量,且**不得短于 CLI 的 -t 默认(1 小时)**
663
663
  // —— 短了就会出现「什么都没显示 · 调用失败 context deadline exceeded」。
664
- if DefaultArkClawTimeout < 10*time.Minute {
665
- t.Errorf("DefaultArkClawTimeout = %v, 实测长任务 304s + CLI -t 默认 600s,应 ≥ 10 分钟", DefaultArkClawTimeout)
664
+ if DefaultArkClawTimeout < time.Hour {
665
+ t.Errorf("DefaultArkClawTimeout = %v, 实测长任务 304s + CLI -t 默认 1h,应 ≥ 1 小时", DefaultArkClawTimeout)
666
666
  }
667
667
  }
668
668
 
@@ -16,8 +16,11 @@ package agent
16
16
  // stdin:stream-json claude/codebuddy 原生 stream-json input(图片走 content block)
17
17
  // flag:-a llm CLI 原生 --attachment
18
18
  // part:file arkclaw 走 A2A 原生 file part(base64 inline)
19
+ // sdk:content-blocks dsh 的 SDK 通道(默认)把图片作为原生 image content block
20
+ // 发出去(base64 inline,真进模型上下文);显式切 headless 后
21
+ // 没有图片通道 → 退化为下面的 prompt 档
19
22
  // prompt 无原生输入通道:把绝对路径写进提示词,靠引擎的读文件工具看
20
- // (trae / openclaw / dsh —— 需要工具可用,CLI 层会提示)
23
+ // (trae / openclaw —— 需要工具可用,CLI 层会提示)
21
24
  // codebuddy-gateway 也归这一档:它走 payload.attachments 原生字段,
22
25
  // 但网关把它渲染成路径清单塞进 prompt(图不进模型上下文)
23
26
 
@@ -111,7 +114,16 @@ func AttachmentSupportOf(engine string) string {
111
114
  return "flag:-a"
112
115
  case "arkclaw":
113
116
  return "part:file"
114
- case "trae", "openclaw", "dsh", "codebuddy-gateway":
117
+ case "dsh":
118
+ // SDK 通道(默认)把图片作为原生 image content block 发出去(真进模型上下文);
119
+ // 显式把通道切成 headless 后没有图片通道,只能退化为提示词里的路径。
120
+ // dshProfileName() 与引擎的 effectiveProfile 同规则(看 MAGIC_AGENT_DSH_PROFILE),
121
+ // 这里让 --engines 的能力字段与实际通道一致,不虚报原生。
122
+ if dshProfileName() == dshSDKProfile {
123
+ return "sdk:content-blocks"
124
+ }
125
+ return "prompt"
126
+ case "trae", "openclaw", "codebuddy-gateway":
115
127
  // codebuddy-gateway 走 payload.attachments 原生字段(urlType=local-path),
116
128
  // 但网关把它渲染成「附件清单 + 路径」塞进 prompt —— 图**不会**以图片形式
117
129
  // 进模型上下文,故如实报 prompt 而不是 part:file。
@@ -158,9 +170,10 @@ func AppendDefaultOn(engine string) bool {
158
170
 
159
171
  // AttachmentPromptSection 把附件以「路径清单」的形式拼进提示词正文。
160
172
  //
161
- // 这是**没有原生附件输入通道**的引擎(trae / openclaw / dsh)的兜底:把绝对路径说给模型,
162
- // 由它自己的读文件工具去看。因此只有在工具可用时才有意义 —— CLI 层会在
163
- // --tools off 时给出明确提示(不静默丢附件)。
173
+ // 这是**没有原生附件输入通道**的引擎(trae / openclaw)以及 dsh 的 headless 回退路
174
+ // 的兜底:把绝对路径说给模型,由它自己的读文件工具去看。因此只有在工具可用时才有
175
+ // 意义 —— CLI 层会在 --tools off 时给出明确提示(不静默丢附件)。dsh 的 SDK 通道不走
176
+ // 这里:图片作为原生 image 块直接发(见 dshPromptBlocks)。
164
177
  func AttachmentPromptSection(atts []Attachment) string {
165
178
  if len(atts) == 0 {
166
179
  return ""
@@ -107,6 +107,7 @@ func TestNewAttachmentFallsBackToExtension(t *testing.T) {
107
107
  }
108
108
 
109
109
  func TestAttachmentSupportOf(t *testing.T) {
110
+ t.Setenv(DshProfileEnv, "") // dsh 默认走 SDK 通道 → 原生收图
110
111
  want := map[string]string{
111
112
  "codex": "flag:-i",
112
113
  "claude": "stdin:stream-json",
@@ -116,7 +117,7 @@ func TestAttachmentSupportOf(t *testing.T) {
116
117
  "arkclaw": "part:file",
117
118
  "trae": "prompt",
118
119
  "openclaw": "prompt",
119
- "dsh": "prompt",
120
+ "dsh": "sdk:content-blocks",
120
121
  }
121
122
  for engine, w := range want {
122
123
  if got := AttachmentSupportOf(engine); got != w {
@@ -127,6 +128,11 @@ func TestAttachmentSupportOf(t *testing.T) {
127
128
  if got := AttachmentSupportOf("nope"); got != "prompt" {
128
129
  t.Errorf("未知引擎应为 prompt, got %q", got)
129
130
  }
131
+ // dsh 显式切 headless → 没有图片通道,退化为提示词路径
132
+ t.Setenv(DshProfileEnv, dshDefaultProfile)
133
+ if got := AttachmentSupportOf("dsh"); got != "prompt" {
134
+ t.Errorf("headless 下 AttachmentSupportOf(dsh) = %q want prompt", got)
135
+ }
130
136
  }
131
137
 
132
138
  func TestAppendAttachmentSectionFallback(t *testing.T) {
@@ -127,7 +127,6 @@ func TestCapabilitiesDocumentedEngines(t *testing.T) {
127
127
  }{
128
128
  {"trae", &TraeEngine{}},
129
129
  {"openclaw", &OpenClawEngine{}},
130
- {"dsh", &DshEngine{}},
131
130
  } {
132
131
  caps := capabilitiesOfEngine(tc.e)
133
132
  if !hasCap(caps, CapAttachmentPrompt) {
@@ -138,6 +137,17 @@ func TestCapabilitiesDocumentedEngines(t *testing.T) {
138
137
  }
139
138
  }
140
139
 
140
+ // dsh:SDK 通道(默认)把图片作为原生 image block 发出 → attachment.native。
141
+ // 显式切 headless 后没有图片通道 → 退化为 attachment.prompt(能力随实际通道走)。
142
+ t.Setenv(DshProfileEnv, "")
143
+ if caps := capabilitiesOfEngine(&DshEngine{}); !hasCap(caps, CapAttachmentNative) || hasCap(caps, CapAttachmentPrompt) {
144
+ t.Errorf("dsh(SDK 通道) 应有 %s 而非 %s,实际 %v", CapAttachmentNative, CapAttachmentPrompt, caps)
145
+ }
146
+ t.Setenv(DshProfileEnv, dshDefaultProfile)
147
+ if caps := capabilitiesOfEngine(&DshEngine{}); !hasCap(caps, CapAttachmentPrompt) || hasCap(caps, CapAttachmentNative) {
148
+ t.Errorf("dsh(headless) 应有 %s 而非 %s,实际 %v", CapAttachmentPrompt, CapAttachmentNative, caps)
149
+ }
150
+
141
151
  // arkclaw 的附件走 A2A 原生 file part → 应给出 attachment.native。
142
152
  // 这条直接查表:ArkClawEngine 的名字来自配置(不是常量),不适合直接构造。
143
153
  if got := AttachmentSupportOf("arkclaw"); got != "part:file" {
@@ -3,15 +3,27 @@ package agent
3
3
  // claude.go - Claude Code CLI 引擎。
4
4
  //
5
5
  // 后端:claude(Claude Code,本机 /opt/homebrew/bin/claude)。
6
- // 非交互模式:-p(--print)+ --output-format json,拿到稳定 envelope:
7
6
  //
8
- // {"type":"result","subtype":"success","result":"<正文>",
9
- // "session_id":"...","is_error":false, "total_cost_usd":...}
7
+ // ## 提示词一律走 stdin(2026-10-01 改,Windows 实测)
10
8
  //
11
- // 关键 flags:
9
+ // 以前用 `-p <prompt>` 把提示词当**位置参数**传。实测在 Windows 上这是
10
+ // 结构性不可靠的:npm 装的 claude 是 `claude.cmd` 批处理,含换行的位置参数
11
+ // 会被 cmd.exe 拆坏,子进程拿不到完整 prompt 就**静默退 0、零输出**
12
+ //(对照实验:同一命令单行 20 行 / 含换行 0 行;非流式同样整段丢内容)。
13
+ //
14
+ // 现在 Complete 与 Stream **都**走 `--input-format stream-json` 的 stdin
15
+ // 通道(streamjson.go),提示词永不作为 argv。unix 上协议完全相同,
16
+ // 故**不加平台分支** —— 一条路径两平台通吃。
17
+ //
18
+ // 收尾正文从 NDJSON 的 result 行取(这条协议要求 --output-format 也是
19
+ // stream-json),故连非流式的 Complete 也要归约一遍事件。
20
+ //
21
+ // ## 关键 flags:
12
22
  //
13
23
  // --model <m> 传裸模型名(sonnet / opus / claude-sonnet-4-6...)
14
24
  // --tools "" 禁用全部内置工具,纯 chat 一次成型
25
+ // (Windows 上经 launch.go 改写成 --tools=,
26
+ // 空串作独立参数会被 cmd.exe 丢弃)
15
27
  // --permission-mode <mode> 四档权限档位(见 permission.go):
16
28
  // manual → default;accept-edits → acceptEdits;
17
29
  // auto → auto;full → bypassPermissions(默认档)
@@ -39,7 +51,6 @@ package agent
39
51
 
40
52
  import (
41
53
  "context"
42
- "encoding/json"
43
54
  "fmt"
44
55
  "io"
45
56
  "os"
@@ -57,8 +68,8 @@ type ClaudeEngine struct {
57
68
  // Name 实现 Engine。
58
69
  func (e *ClaudeEngine) Name() string { return "claude" }
59
70
 
60
- // DefaultClaudeTimeout 单次尝试默认超时。
61
- const DefaultClaudeTimeout = 5 * time.Minute
71
+ // DefaultClaudeTimeout 单次尝试默认超时(2026-10-10 由 5 分钟统一上调到 1 小时)。
72
+ const DefaultClaudeTimeout = time.Hour
62
73
 
63
74
  // DefaultClaudeModel claude 引擎的默认模型(Claude Code CLI 默认即主模型,
64
75
  // 显式传空串别名让 CLI 自己决定,不在代理层硬编码具体版本号)。
@@ -121,17 +132,6 @@ func (e *ClaudeEngine) ListModels(_ context.Context) ([]string, error) {
121
132
  return models, nil
122
133
  }
123
134
 
124
- // claudeResult 对应 --output-format json 的 envelope。
125
- type claudeResult struct {
126
- Type string `json:"type"`
127
- Subtype string `json:"subtype"`
128
- IsError bool `json:"is_error"`
129
- Result string `json:"result"`
130
- SessionID string `json:"session_id"`
131
- Model string `json:"model"`
132
- TotalCostUSD float64 `json:"total_cost_usd"`
133
- }
134
-
135
135
  // Complete 实现 Engine:单次调用 claude CLI。
136
136
  func (e *ClaudeEngine) Complete(ctx context.Context, req Request) (Response, error) {
137
137
  start := time.Now()
@@ -145,54 +145,14 @@ func (e *ClaudeEngine) Complete(ctx context.Context, req Request) (Response, err
145
145
  return Response{}, fmt.Errorf("claude: empty prompt")
146
146
  }
147
147
 
148
- // 附件(截图):claude 的 -p 文本模式收不了图,唯一的原生通道是
149
- // --input-format stream-json(图片作为 content block 走 stdin)。该模式下
150
- // --output-format 也必须是 stream-json,所以这里跑一遍流式协议、把事件归约成
151
- // 单次结果返回(Complete 的语义就是「一次性拿最终答案」,不给调用方发事件)。
152
- if hasImageAttachment(req.Attachments) {
153
- return e.completeWithAttachments(ctx, req, prompt, start)
154
- }
155
-
156
- args := e.buildArgs(req, prompt)
157
-
158
- // workspace:claude 没有工作目录 flag,子进程 cwd 就是原生方式(见 Request.Workspace)
159
- stdout, stderr, err := runCLIIn(ctx, req.Workspace, bin, args...)
160
- if err != nil {
161
- return Response{}, wrapCliError("claude", stdout, stderr, err)
162
- }
163
-
164
- raw := strings.TrimSpace(stdout)
165
- if raw == "" {
166
- return Response{}, fmt.Errorf("claude CLI returned empty output")
167
- }
168
-
169
- var env claudeResult
170
- if err := json.Unmarshal([]byte(raw), &env); err != nil {
171
- // 个别版本对 --print + json 仍吐纯文本:原样返回。
172
- return Response{
173
- Text: raw,
174
- Model: req.Model,
175
- Latency: time.Since(start),
176
- }, nil
177
- }
178
- if env.IsError {
179
- return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", env.Subtype, truncateStr(env.Result, 500))
180
- }
181
- if env.Result == "" {
182
- return Response{}, fmt.Errorf("claude CLI returned empty result")
183
- }
184
- return Response{
185
- Text: env.Result,
186
- Model: env.Model,
187
- SessionID: env.SessionID,
188
- Latency: time.Since(start),
189
- }, nil
190
- }
191
-
192
- // completeWithAttachments 走 stream-json 输入通道跑一次带附件的调用,
193
- // 把事件归约成单次 Response(不向调用方发增量事件)。
194
- func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request, prompt string, start time.Time) (Response, error) {
195
- bin := e.bin()
148
+ // 提示词走 stdin 的 stream-json 通道(**所有**调用,含无附件的纯文本)。
149
+ //
150
+ // 为什么不再用 `-p <prompt>` 位置参数(2026-10-01 改,Windows 实测):
151
+ // 位置参数在 Windows 上会被 .cmd 批处理拆坏 —— 含换行时整段内容丢失,
152
+ // claude 收到空 prompt 后回一句 "How can I help you today?"(实测)。
153
+ // unix 上位置参数本来能用,但统一走 stdin 可让两平台行为完全一致,
154
+ // 省掉一类「只在某个平台复现」的问题。代价是要多解析一层 NDJSON,
155
+ // 而 streamJSONArgs/runStreamJSONIn 已是附件路径的现成实现。
196
156
  args := streamJSONArgs(e.buildArgsBase(req), prompt, false)
197
157
  stdin, err := streamJSONUserLine(prompt, req.Attachments)
198
158
  if err != nil {
@@ -200,12 +160,19 @@ func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request,
200
160
  }
201
161
  acc := &streamAccumulator{Engine: e.Name(), OnSessionID: req.OnSessionID}
202
162
  var fin streamJSONResult
203
- seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, strings.NewReader(stdin), acc, &fin)
163
+ // stderr 尾巴留一份:非零退出与「没有 result 行」都要拿它拼可读原因
164
+ //(CLI 崩溃 / 鉴权与额度失败的原因只出现在它的 stderr 里)。
165
+ var cliStderr streamStderrBuf
166
+ seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, strings.NewReader(stdin), acc, &fin, &cliStderr)
204
167
  if err != nil {
205
- return Response{}, wrapCliError("claude", "", "", err)
168
+ return Response{}, wrapCliError("claude", "", cliStderr.String(), err)
206
169
  }
207
170
  if !seen {
208
- return Response{}, fmt.Errorf("claude CLI stream ended without result line")
171
+ // 补终态事件(2026-10-02):非流式路径通常没有 onEvent,nil 时静默跳过。
172
+ // 有 onEvent 的场景(观察者)才能看到「这一轮异常结束」而不是凭空消失。
173
+ err := streamNoResultErr("claude", cliStderr.String())
174
+ acc.emitTurnFailed(acc.SessionID, err)
175
+ return Response{}, err
209
176
  }
210
177
  if fin.IsError {
211
178
  return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
@@ -222,14 +189,16 @@ func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request,
222
189
  }, nil
223
190
  }
224
191
 
225
- // buildArgs 构造 claude CLI 参数(Complete 与 Stream 共用)。
226
- // outputFormat:"json"(单结果 envelope)或 "stream-json"(流式 NDJSON)。
192
+ // buildArgs 构造 claude CLI 参数(含末尾的位置参数 prompt)。
193
+ //
194
+ // ⚠️ 2026-10-01 起**生产路径已不再用它**:Complete 与 Stream 都把提示词
195
+ // 走 stdin(见各自注释里的理由),只有测试与「显式要位置参数」的场合还可能用。
196
+ // 保留是因为 buildArgsBase 的语义("不含位置参数")仍被 streamJSONArgs 依赖。
227
197
  func (e *ClaudeEngine) buildArgs(req Request, prompt string) []string {
228
198
  return append(e.buildArgsBase(req), prompt)
229
199
  }
230
200
 
231
- // buildArgsBase 构造参数(不含末尾的位置参数 prompt)——给附件场景复用:
232
- // 那条路提示词走 stdin,不能再作为命令行参数传(streamJSONArgs 负责剔除)。
201
+ // buildArgsBase 构造参数(不含末尾的位置参数 prompt)——提示词走 stdin 的路径复用它。
233
202
  func (e *ClaudeEngine) buildArgsBase(req Request) []string {
234
203
  // 工具模式 × 权限档位(四档模型见 permission.go):
235
204
  // off --tools ""(无工具调用,权限档位无意义,不传)
@@ -252,7 +221,13 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
252
221
  case tools.IsOff():
253
222
  args = append(args, "--tools", "")
254
223
  case tools.IsOn():
255
- args = append(args, "--permission-mode", claudePermissionMode(req.Permission))
224
+ // ⚠️ 2026-10-01:必须显式 --tools default —— 只传 --permission-mode
225
+ // 不传 --tools 时,claude CLI 在 stream-json 模式下不发 result 行
226
+ // (实测):表现为「stream ended without result line」,魔法端把整轮
227
+ // 标 turn_failed、UI 一句话都渲染不出来。--tools default 才是「开工具」
228
+ // 的正路(与 ""=关、name1,name2=白名单 同列;"default" 是「全部内置」)。
229
+ args = append(args, "--tools", "default",
230
+ "--permission-mode", claudePermissionMode(req.Permission))
256
231
  default: // allowlist
257
232
  args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
258
233
  "--permission-mode", claudePermissionMode(req.Permission))
@@ -262,6 +237,14 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
262
237
  } else {
263
238
  args = append(args, "--model", DefaultClaudeModel)
264
239
  }
240
+ // 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
241
+ // 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
242
+ if lv := ReasoningLevel(req.Reasoning); lv != "" {
243
+ if lv == "minimal" {
244
+ lv = "low"
245
+ }
246
+ args = append(args, "--effort", lv)
247
+ }
265
248
  // 统一参数矩阵:`--settings` 只接受**一份**载荷(文件路径或内联 JSON),
266
249
  // 所以 MaxTokens 的 env 注入、四档模型的 sandbox / autoMode / permissions
267
250
  // 必须合并进同一个 JSON —— 见 permission.go 的 agentSettingsPayload。
@@ -287,102 +270,57 @@ func (e *ClaudeEngine) Stream(ctx context.Context, req Request, onEvent func(Str
287
270
  return StreamResult{}, fmt.Errorf("claude: empty prompt")
288
271
  }
289
272
 
290
- // 附件(截图)→ stream-json 输入通道:提示词不再当位置参数,改走 stdin。
291
- // 常驻会话(Request.Append 非 nil)走同一条通道 —— 区别只在 stdin 是个**流**:
273
+ // 提示词一律走 stdin 的 stream-json 输入通道(不再当位置参数)。
274
+ //
275
+ // 为什么这是**默认**而不是「附件时才走」(2026-10-01 改,Windows 实测):
276
+ // 位置参数在 Windows 上是结构性不可靠的 —— npm 装的 claude 是 .cmd 批处理,
277
+ // 含换行的位置参数会被 cmd.exe 拆坏,子进程拿不到完整 prompt 就
278
+ // **静默退 0、零输出**(对照实验:同一命令单行 20 行 / 含换行 0 行)。
279
+ // 不只是流式受损:非流式的 -p 同样整段丢内容。
280
+ // agents-anywhere 对此的处理是提示词全走 SDK stdin(connector/runtimes/claude/
281
+ // sdk/client.py:160 的 query(content)),从架构上就没有这条破路。
282
+ //
283
+ // unix 上走 stdin 同样正确(协议一致),故**不加平台分支** ——
284
+ // 一条路径两平台通吃,避免两套行为漂移。
285
+ //
286
+ // 常驻会话(Request.Append 非 nil)的区别只在 stdin 是个**流**:
292
287
  // 追加消息源源不断写进去,通道关闭才 EOF(子进程随之收尾)。
293
- args := e.buildArgs(req, prompt)
294
- if req.Append != nil || hasImageAttachment(req.Attachments) {
295
- var stdin io.Reader
296
- if req.Append != nil {
297
- pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
298
- if perr != nil {
299
- return StreamResult{}, perr
300
- }
301
- stdin = pipe
302
- } else {
303
- line, serr := streamJSONUserLine(prompt, req.Attachments)
304
- if serr != nil {
305
- return StreamResult{}, serr
306
- }
307
- stdin = strings.NewReader(line)
308
- }
309
- args = streamJSONArgs(e.buildArgsBase(req), prompt, true)
310
- acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
311
- var fin streamJSONResult
312
- seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin)
313
- if err != nil {
314
- return StreamResult{}, err
315
- }
316
- if !seen {
317
- return StreamResult{}, fmt.Errorf("claude CLI stream ended without result line")
318
- }
319
- if fin.IsError {
320
- return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
321
- }
322
- text := finalizeStreamText(fin, acc)
323
- if text == "" {
324
- return StreamResult{}, errEmptyStreamContent("claude")
325
- }
326
- return StreamResult{
327
- Response: Response{
328
- Engine: e.Name(),
329
- Text: text,
330
- Model: fin.Model,
331
- SessionID: fin.SessionID,
332
- Latency: time.Since(start),
333
- },
334
- Thinking: acc.Thinking.String(),
335
- Tools: acc.Tools,
336
- }, nil
337
- }
338
-
339
- // json → stream-json:替换 --output-format 值并追加流式 flags。
340
- for i := range args {
341
- if args[i] == "--output-format" {
342
- args[i+1] = "stream-json"
343
- break
344
- }
345
- }
346
- args = append(args, "--include-partial-messages", "--verbose")
347
-
348
- acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent}
349
- var fin struct {
350
- Type string `json:"type"`
351
- Subtype string `json:"subtype"`
352
- IsError bool `json:"is_error"`
353
- Result string `json:"result"`
354
- SessionID string `json:"session_id"`
355
- Model string `json:"model"`
356
- }
357
- seenResult := false
358
-
359
- err := runStreamCLIIn(ctx, req.Workspace, bin, args, func(line string) error {
360
- isResult, perr := acc.handleNDJSONLine(line)
288
+ var stdin io.Reader
289
+ if req.Append != nil {
290
+ pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
361
291
  if perr != nil {
362
- return perr
292
+ return StreamResult{}, perr
363
293
  }
364
- if isResult {
365
- _ = json.Unmarshal([]byte(line), &fin)
366
- seenResult = true
294
+ stdin = pipe
295
+ } else {
296
+ line, serr := streamJSONUserLine(prompt, req.Attachments)
297
+ if serr != nil {
298
+ return StreamResult{}, serr
367
299
  }
368
- return nil
369
- })
300
+ stdin = strings.NewReader(line)
301
+ }
302
+ args := streamJSONArgs(e.buildArgsBase(req), prompt, true)
303
+ acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
304
+ var fin streamJSONResult
305
+ var cliStderr streamStderrBuf
306
+ seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin, &cliStderr)
370
307
  if err != nil {
371
308
  return StreamResult{}, err
372
309
  }
373
- if !seenResult {
374
- return StreamResult{}, fmt.Errorf("claude CLI stream ended without result line")
310
+ if !seen {
311
+ // 补终态事件:流被截断(引擎崩 / 被杀 / 没吐 result)时,已流出去的半截正文
312
+ // 在事件流上永远停在 running —— 消费方看到的是一条悬空 item。
313
+ // 对齐 agents-anywhere 的 failed_terminal_event(见 emitTurnFailed)。
314
+ err := streamNoResultErr("claude", cliStderr.String())
315
+ acc.emitTurnFailed(acc.SessionID, err)
316
+ return StreamResult{}, err
375
317
  }
376
318
  if fin.IsError {
377
319
  return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
378
320
  }
379
-
380
- text := fin.Result
381
- if text == "" {
382
- text = strings.TrimSpace(acc.Text.String())
383
- }
321
+ text := finalizeStreamText(fin, acc)
384
322
  if text == "" {
385
- return StreamResult{}, fmt.Errorf("claude CLI returned empty result")
323
+ return StreamResult{}, errEmptyStreamContent("claude")
386
324
  }
387
325
  return StreamResult{
388
326
  Response: Response{