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
@@ -27,6 +27,7 @@ import (
27
27
  // │ Continue │ --continue │ --continue │ --resume(自动选最近会话) │ -c │ resume --last │ sessions 查最新 id │ 不支持(显式报错)⑨ │
28
28
  // │ MaxTokens │ --settings(env 注入)① │ --settings(env 注入)① │ 不支持(静默忽略)② │ -o max_tokens │ 不支持(静默忽略)② │ 静默忽略② │ 静默忽略② │
29
29
  // │ Temperature │ 不支持 │ 不支持 │ 不支持(静默忽略)② │ -o temperature │ 不支持(静默忽略)② │ 静默忽略② │ 静默忽略② │
30
+ // │ Reasoning │ --effort <l> │ --effort <l> │ 不支持(忽略) │ 不支持 │ -c reasoning_effort │ 不支持 │ 不支持 │
30
31
  // │ Tools │ --tools / 档位③ │ --tools / 档位③ │ --disallowed-tool / 全放行③│ 不支持 │ -s 沙箱档位④ │ 内嵌 agent(忽略) │ 内嵌 agent(忽略)⑨ │
31
32
  // │ Permission │ --permission-mode⑧ │ --permission-mode⑧ │ 未接线(yaml 配置) │ 不支持 │ 未接线(沙箱×审批两轴)│ 未接线(exec.mode) │ 未接线(配置层预设)⑨│
32
33
  // │ SystemPrompt │ --append-system-prompt │ --append-system-prompt │ 展平进 prompt 头部 │ -s │ -c developer_instr.⑤ │ 展平进 prompt 头部 │ 展平进 prompt 头部 │
@@ -100,13 +101,22 @@ import (
100
101
  // SessionID / Continue:headless 一次调用 = 一个**全新的持久化 Agent**,官方 app 参数表里只有任务文本、没有续接参数 → **显式报错**(静默开新会话会让调用方以为上下文接上了)。
101
102
  // MaxTokens / Temperature:无对应 flag(超时是 provider 级 settings 的 streamIdleTimeoutMs,不是 CLI flag)→ 静默忽略②。
102
103
  // Tools:dsh 自带 agent 工具循环(base bundle 的 read/write/edit + Bash),增删工具只能改 profile bundle / cordis.patch.yml,**无命令行级逐工具开关** → 整体忽略(与 openclaw 的「内嵌 agent」同构)。
103
- // Permission:新会话默认 workspace-write 预设(写入限工作区 + 平台临时目录),另有 read-only 预设,进程回退由环境变量 DSH_PERMISSION_MODE 决定;两个预设无法表达四档模型里的「沙箱关闭 / 无审批」(第 4 档)→ 不接线(none),显式传 --permission 在 CLI 层 exit 2。
104
+ // Permission:**已接线** —— 档位走子进程环境变量 DSH_PERMISSION_MODE(dsh 无逐调用权限 flag)。四档映射 read-only / workspace-write / danger-full-access,第 4 档 = danger-full-access(沙箱关闭 + 审批 never)。核实来源与两处已知差异见 permission.go::dshPermissionMode。
104
105
  // SystemPrompt:无独立 system 注入 flag → 展平进 prompt 头部(同 trae/openclaw)。
105
106
  // Workspace:官方语义就是「调用时所在目录即默认 workspace 根」→ 子进程 cwd⑥。
106
107
  // JSONSchema:无原生约束 → 输出后处理抽 JSON(同 llm/openclaw/arkclaw)。
107
108
  // Attachments:无原生输入通道 → 绝对路径写进提示词(同 trae/openclaw)。
108
109
  // Stream:实现了,但**只流推理** —— 推理增量走 stderr("dsh: reasoning:" 起头)→ 逐条 thinking 事件;正文在 turn 结束时一次性给出(一条 text 事件)。正文级流式需 --profile sdk 的 JSON-RPC,不在本引擎范围。
109
110
  //
111
+ // ⑩ Reasoning(推理/思考档位,--reasoning,默认 max;取值 minimal/low/medium/high/xhigh/max,off = 不传):
112
+ //
113
+ // claude `--effort <level>`(只认 low/medium/high/xhigh/max,minimal 降级为 low,已实测 --help)
114
+ // codebuddy `--effort <level>`(同上五档 + minimal;codebuddy-ai 同族同映射)
115
+ // codex `-c model_reasoning_effort="<level>"`(exec 与 exec resume 均生效)
116
+ // trae / llm / openclaw / dsh / arkclaw 无对应参数 → 静默忽略(用户显式传时 CLI 层打一行提示);
117
+ // llm 直连的思考由 ~/.magic-agent/models.json 的 extraBody 决定,本字段不注入请求体。
118
+ // 机器可读判断见 ReasoningSupportOf。
119
+ //
110
120
  // cliBase 描述一个 CLI 引擎的可执行文件探测配置。
111
121
  // 各引擎保留顶层 BinPath 字段与一行式 Name(),以保证测试中零值/复合字面量构造的兼容性;
112
122
  // 本结构体仅承载探测链配置,由各引擎的 bin()/Detect() 委托调用。
@@ -169,7 +179,13 @@ var (
169
179
  binName: "llm",
170
180
  envVar: "MAGIC_AGENT_LLM_BIN",
171
181
  candidates: []string{
172
- // venv 优先:绕开 Homebrew Python 3.14 的 pip truststore 兼容问题
182
+ // venv 优先:绕开 Homebrew Python 3.14 的 pip truststore 兼容问题。
183
+ // ⚠️ **两个平台的 venv 布局不同,两条都要列**(2026-10-01 实测踩过):
184
+ // Windows 的 venv 可执行文件在 `Scripts\`,Unix 在 `bin/`。只列 Unix 那条时,
185
+ // `npm i -g magic-agent` 的 postinstall 明明把 llm 装好了(install.js 自己
186
+ // 按 win32 选了 Scripts),`--engines` 却仍报 "llm CLI not found" ——
187
+ // 安装器与探测器对「llm 装在哪」各说各话,且两边都不报错,很难查。
188
+ "~/.llm-venv/Scripts/llm.exe",
173
189
  "~/.llm-venv/bin/llm",
174
190
  "/opt/homebrew/bin/llm",
175
191
  "/usr/local/bin/llm",
@@ -391,6 +407,34 @@ func InstallCommandOf(engine string) string {
391
407
  return ""
392
408
  }
393
409
 
410
+ // ReasoningLevel 归一化推理档位:空串与 off/none/disabled 都返回 ""(= 不追加参数,
411
+ // 让 CLI 用自身默认);其余原样小写返回。
412
+ func ReasoningLevel(level string) string {
413
+ switch level = strings.ToLower(strings.TrimSpace(level)); level {
414
+ case "", "off", "none", "disabled":
415
+ return ""
416
+ }
417
+ return level
418
+ }
419
+
420
+ // ValidReasoningLevel 校验 --reasoning 的取值("max" 为 CLI 默认档)。
421
+ func ValidReasoningLevel(level string) bool {
422
+ switch ReasoningLevel(level) {
423
+ case "", "minimal", "low", "medium", "high", "xhigh", "max":
424
+ return true
425
+ }
426
+ return false
427
+ }
428
+
429
+ // ReasoningSupportOf 报告引擎是否有原生推理档位参数(无参引擎传了也会被忽略)。
430
+ func ReasoningSupportOf(engine string) bool {
431
+ switch engine {
432
+ case "claude", "codebuddy", "codebuddy-ai", "codex":
433
+ return true
434
+ }
435
+ return false
436
+ }
437
+
394
438
  /* EngineVersionOf 尽力读出某引擎 CLI 的**当前版本**(读不到就空串 —— 绝不编)。
395
439
  *
396
440
  * 为什么要它(2026-09-23 用户:「引擎检测除了 a2a 的 其他也要支持有升级」):
@@ -248,6 +248,35 @@ func TestCLIResolveEnvVar(t *testing.T) {
248
248
  }
249
249
  }
250
250
 
251
+ // TestLLMBaseCoversBothVenvLayouts llm 的 venv 探测候选必须同时覆盖
252
+ // **两个平台的 venv 布局**:Windows 是 `Scripts\`,Unix 是 `bin/`。
253
+ //
254
+ // 这条挡的是 2026-10-01 实测踩到的一个真实故障:`npm i -g magic-agent` 的
255
+ // postinstall 明明把 llm 装进了 `~/.llm-venv/Scripts/`(install.js 自己按
256
+ // win32 选 Scripts),`--engines` 却仍报 "llm CLI not found" —— 因为
257
+ // llmBase.candidates 只列了 Unix 的 `bin/` 一条。安装器与探测器各说各话,
258
+ // 两边都不报错,排查成本很高。
259
+ //
260
+ // 断言只查「候选列表里有哪两条」,不碰真实文件系统:路径在不同机器上不一定存在。
261
+ func TestLLMBaseCoversBothVenvLayouts(t *testing.T) {
262
+ // 与 npm/install.js 的 venvDir() 保持一致:
263
+ // process.platform === "win32" ? "Scripts" : "bin"
264
+ want := map[string]bool{
265
+ "~/.llm-venv/Scripts/llm.exe": false, // Windows
266
+ "~/.llm-venv/bin/llm": false, // Unix
267
+ }
268
+ for _, c := range llmBase.candidates {
269
+ if _, ok := want[c]; ok {
270
+ want[c] = true
271
+ }
272
+ }
273
+ for c, found := range want {
274
+ if !found {
275
+ t.Errorf("llmBase.candidates 缺少 %q —— 另一个平台的 venv 布局会探测不到", c)
276
+ }
277
+ }
278
+ }
279
+
251
280
  /* ── 引擎版本读取(2026-09-23)──
252
281
  * 用户:「引擎检测除了 a2a 的 其他也要支持有升级」—— 版本号是「升级」可验证的前提
253
282
  * (重探一次比对旧新,才能如实说「已升级」或「版本未变」)。 */
@@ -155,6 +155,62 @@ func TestClaudePassesModelFlag(t *testing.T) {
155
155
  }
156
156
  }
157
157
 
158
+ // TestReasoningEffortArgs 推理档位(--reasoning,默认 max)到各引擎参数的映射:
159
+ // claude/codebuddy 走 --effort,codex 走 -c model_reasoning_effort;空串 = 不传。
160
+ func TestReasoningEffortArgs(t *testing.T) {
161
+ hasPair := func(args []string, k, v string) bool {
162
+ for i := 0; i+1 < len(args); i++ {
163
+ if args[i] == k && args[i+1] == v {
164
+ return true
165
+ }
166
+ }
167
+ return false
168
+ }
169
+
170
+ claudeArgs := (&ClaudeEngine{}).buildArgsBase(Request{Reasoning: "max"})
171
+ if !hasPair(claudeArgs, "--effort", "max") {
172
+ t.Errorf("claude --effort max 未透传: %v", claudeArgs)
173
+ }
174
+ // claude 不认 minimal,必须降级为 low,否则整轮被 clap 拒绝。
175
+ claudeMin := (&ClaudeEngine{}).buildArgsBase(Request{Reasoning: "minimal"})
176
+ if !hasPair(claudeMin, "--effort", "low") {
177
+ t.Errorf("claude minimal 应降级 low: %v", claudeMin)
178
+ }
179
+ if got := (&ClaudeEngine{}).buildArgsBase(Request{}); hasPair(got, "--effort", "max") {
180
+ t.Errorf("Reasoning 为空时不应追加 --effort: %v", got)
181
+ }
182
+
183
+ cbArgs := (&CodeBuddyEngine{}).buildArgsBase(Request{Reasoning: "xhigh"})
184
+ if !hasPair(cbArgs, "--effort", "xhigh") {
185
+ t.Errorf("codebuddy --effort xhigh 未透传: %v", cbArgs)
186
+ }
187
+
188
+ codexArgs := (&CodexEngine{}).buildArgs(Request{Reasoning: "max"}, "hi", "", "")
189
+ if !hasPair(codexArgs, "-c", `model_reasoning_effort="max"`) {
190
+ t.Errorf("codex model_reasoning_effort 未透传: %v", codexArgs)
191
+ }
192
+
193
+ // off/none/disabled 一律归一为「不传」。
194
+ for _, v := range []string{"off", "none", "disabled", " OFF "} {
195
+ if lv := ReasoningLevel(v); lv != "" {
196
+ t.Errorf("ReasoningLevel(%q) = %q, want \"\"", v, lv)
197
+ }
198
+ }
199
+ if ValidReasoningLevel("ultra") {
200
+ t.Error("ValidReasoningLevel(ultra) 应为 false")
201
+ }
202
+ for _, e := range []string{"claude", "codebuddy", "codebuddy-ai", "codex"} {
203
+ if !ReasoningSupportOf(e) {
204
+ t.Errorf("ReasoningSupportOf(%s) 应为 true", e)
205
+ }
206
+ }
207
+ for _, e := range []string{"trae", "llm", "openclaw", "dsh", "arkclaw"} {
208
+ if ReasoningSupportOf(e) {
209
+ t.Errorf("ReasoningSupportOf(%s) 应为 false", e)
210
+ }
211
+ }
212
+ }
213
+
158
214
  func TestClaudeErrorEnvelope(t *testing.T) {
159
215
  cli := writeFakeCLI(t, "claude", `#!/bin/sh
160
216
  echo '{"type":"result","subtype":"error_max_turns","is_error":true,"result":"超出轮次"}'
@@ -213,6 +269,51 @@ echo '{"type":"result","is_error":false,"result":"你好,世界"}'
213
269
  }
214
270
  }
215
271
 
272
+ // 「协议没跑完」的错误必须带上 CLI 的 stderr 尾巴(2026-10-09)。
273
+ //
274
+ // 用户报障截图里整条错误只有一句「codebuddy-ai CLI stream ended without result line」,
275
+ // 而真正原因(CLI 崩了 / 鉴权或额度失败)几乎只出现在它自己的 stderr 里 ——
276
+ // 不带出来就无从下手。这条错误也要求 CLI **exit 0 但没吐 result 行**
277
+ // (非零退出走的是 wrapCliError 那条路)。
278
+ func TestStreamNoResultLineCarriesStderr(t *testing.T) {
279
+ t.Run("codebuddy", func(t *testing.T) {
280
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
281
+ cat >/dev/null
282
+ echo '{"type":"system","subtype":"init"}'
283
+ echo 'Error: 401 unauthorized, api key invalid' >&2
284
+ exit 0
285
+ `)
286
+ _, err := (&CodeBuddyEngine{BinPath: cli}).Complete(context.Background(), Request{
287
+ Messages: []Message{{Role: "user", Content: "hi"}},
288
+ })
289
+ if err == nil {
290
+ t.Fatal("没见到 result 行必须报错")
291
+ }
292
+ if !strings.Contains(err.Error(), "stream ended without result line") ||
293
+ !strings.Contains(err.Error(), "401 unauthorized") {
294
+ t.Errorf("错误应同时含「协议未跑完」与 stderr 原因,实得:%v", err)
295
+ }
296
+ })
297
+ t.Run("claude", func(t *testing.T) {
298
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
299
+ cat >/dev/null
300
+ echo '{"type":"system","subtype":"init"}'
301
+ echo 'Error: rate limit exceeded' >&2
302
+ exit 0
303
+ `)
304
+ _, err := (&ClaudeEngine{BinPath: cli}).Complete(context.Background(), Request{
305
+ Messages: []Message{{Role: "user", Content: "hi"}},
306
+ })
307
+ if err == nil {
308
+ t.Fatal("没见到 result 行必须报错")
309
+ }
310
+ if !strings.Contains(err.Error(), "stream ended without result line") ||
311
+ !strings.Contains(err.Error(), "rate limit exceeded") {
312
+ t.Errorf("错误应同时含「协议未跑完」与 stderr 原因,实得:%v", err)
313
+ }
314
+ })
315
+ }
316
+
216
317
  func TestCodeBuddyArrayEnvelope(t *testing.T) {
217
318
  cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
218
319
  echo '[{"type":"system","subtype":"init"},{"type":"assistant","message":{"content":[{"type":"text","text":"部分1"}]}},{"type":"result","result":"最终答案"}]'
@@ -29,6 +29,46 @@ import (
29
29
  // 不继承该前缀;CLI 不需要、也不应该绑定父进程的服务端口。
30
30
  const serverEnvPrefix = "SERVER__"
31
31
 
32
+ // codebuddyEnvPrefix / claudeEnvPrefix 剔除的前缀:宿主 IDE(WorkBuddy)注入
33
+ // 的大批 *_API_KEY / *_BUILTIN_SKILLS_DIR / *_GATEWAY_PASSWORD / *_PROJECT_DIR
34
+ // / *_QIMEI36 / *_SAFE_DELETE_BULK_* 之类环境变量。
35
+ //
36
+ // ⚠️ 2026-10-01 实测:宿主的 CODEBUDDY_MCP_CONFIG 是合法 JSON,但下面这种
37
+ // 配置组合会让 claude CLI 启动时 parse settings 失败、**直接 exit 0、零输出**:
38
+ //
39
+ // --tools default(开工具)
40
+ // --permission-mode bypassPermissions
41
+ // --settings '{"sandbox":{"enabled":false}}'
42
+ // CODEBUDDY_MCP_CONFIG='{"mcpServers":...}'(宿主注入)
43
+ // CODEBUDDY_DISABLE_* / CODEBUDDY_PLUGIN_* / CLAUDE_PLUGIN_*(宿主注入)
44
+ //
45
+ // 表现为:「已完成 5892ms」但 AI 气泡空白,turn_failed 流。
46
+ //
47
+ // 修法(实测有效):**一刀切**剥掉所有 CODEBUDDY_* 与 CLAUDE_* 变量再下传。
48
+ // magic-agent 自己的鉴权走显式 API key(不靠 env),子 CLI 不再依赖任何
49
+ // 宿主 IDE 状态;缺哪些值再由调用方按需通过 extraEnv 注入。
50
+ const codebuddyEnvPrefix = "CODEBUDDY_"
51
+ const claudeEnvPrefix = "CLAUDE_"
52
+
53
+ // baggagePrefix / bashFuncPrefix / bashEnvName 剔除的**精确名字**:
54
+ //
55
+ // BAGGAGE=codebuddy.session_id=... —— WorkBuddy shell runtime 注入;key 不是
56
+ // CODEBUDDY_* 前缀,但值里嵌了 codebuddy 状态,
57
+ // 留着也无用且会让 claude 误读
58
+ // BASH_FUNC_*(rm / rmdir / unlink 等)—— Bash 函数导出,**专门**为 WorkBuddy
59
+ // 的「safe delete」shim 用;其他 CLI 进程用不到,
60
+ // 且函数体会把这些命令变成请求 safe-delete 守护,
61
+ // 大概率破坏子进程工具调用
62
+ // BASH_ENV=...shim/shell-runtime-bash-env.sh —— bash 启动源文件;非 bash 进程不读
63
+ //
64
+ // 2026-10-01 实测:删了 BAGGAGE + BASH_FUNC_* + BASH_ENV 后,claude CLI 的
65
+ // 「Invalid JSON provided to --settings」错误从子进程 stderr 里消失,
66
+ // 流式输出恢复正常。
67
+ const (
68
+ baggageName = "BAGGAGE"
69
+ bashEnvName = "BASH_ENV"
70
+ )
71
+
32
72
  // envDenylist 精确剔除的变量名(前缀规则之外的部分)。
33
73
  var envDenylist = map[string]bool{
34
74
  // 父会话的会话/请求标识:子进程复用会让 CLI 误以为自己是既有会话
@@ -46,6 +86,22 @@ var envDenylist = map[string]bool{
46
86
  // venv 自带隔离,不需要宿主注入;node 系 CLI(claude/codebuddy/trae)不受影响。
47
87
  "PYTHONHOME": true,
48
88
  "PYTHONPATH": true,
89
+
90
+ // 宿主 IDE / workbuddy 注入(2026-10-01 实测):
91
+ // - CLAUDE_PLUGIN_* / CODEBUDDY_PLUGIN_* —— IDE 自己的 hook 注入,引擎读不到也不该读
92
+ // - CODEBUDDY_MCP_CONFIG —— 大段 mcpServers JSON。claude CLI 启动时会把它当自己的
93
+ // settings 试图解析,**直接报「Invalid JSON provided to --settings」、exit 0
94
+ // 零输出**。表现:magic-agent --stream --events 一句话都拿不到,用户看到
95
+ // 「已完成 5892ms」但 AI 气泡空白。剥掉这条是当前唯一不需要改 claude CLI 的修法。
96
+ // - CODEBUDDY_DISABLE_* —— IDE 注入的「关掉某些特性」开关;claude CLI 读不懂
97
+ // 容易误动作,剥掉。
98
+ // - CODEBUDDY_CONFIG_DIR / CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION /
99
+ // CODEBUDDY_SAFE_DELETE_BULK_GUARD —— 宿主配置/沙箱细节,claude/codebuddy
100
+ // 不需要。
101
+ "CODEBUDDY_MCP_CONFIG": true,
102
+ "CODEBUDDY_CONFIG_DIR": true,
103
+ "CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION": true,
104
+ "CODEBUDDY_SAFE_DELETE_BULK_GUARD": true,
49
105
  }
50
106
 
51
107
  // environ 返回供子 CLI 继承的环境变量(已剔除父进程专属项)。
@@ -89,7 +145,19 @@ func ChildEnvWith(extraEnv []string) []string {
89
145
  // 不会少剔除,故这里两个平台统一按大写比较。
90
146
  func envDenied(name string) bool {
91
147
  upper := strings.ToUpper(name)
92
- if strings.HasPrefix(upper, serverEnvPrefix) {
148
+ // 新增的 CODEBUDDY_* / CLAUDE_* 前缀同样按大写比较:Windows 上环境变量名
149
+ // 本身不区分大小写,逐字符比较会漏剔除(同 serverEnvPrefix 的教训)。
150
+ switch {
151
+ case strings.HasPrefix(upper, serverEnvPrefix),
152
+ strings.HasPrefix(upper, codebuddyEnvPrefix),
153
+ strings.HasPrefix(upper, claudeEnvPrefix):
154
+ return true
155
+ }
156
+ if upper == baggageName || upper == bashEnvName {
157
+ return true
158
+ }
159
+ // BASH_FUNC_* —— WorkBuddy 的 safe-delete shim 注入。
160
+ if strings.HasPrefix(upper, "BASH_FUNC_") {
93
161
  return true
94
162
  }
95
163
  return envDenylist[upper]
@@ -0,0 +1,271 @@
1
+ package agent
2
+
3
+ // itemtracker.go - 状态收敛的可复用跟踪器(2026-10-02)。
4
+ //
5
+ // streamAccumulator 内嵌了一份状态收敛逻辑,但那只覆盖走
6
+ // `streamAccumulator` 的三个引擎(claude / codebuddy / trae)。
7
+ // 其余六个引擎组各有自己的累加器结构与 emit 路径:
8
+ //
9
+ // codex codexStreamAcc
10
+ // llm thinkSplitter
11
+ // openclaw acpStreamState
12
+ // dsh / dsh_sdk 各自的 session.event 帧循环(同一引擎两条协议路径)
13
+ // arkclaw SSE 两帧(整段到达)
14
+ // codebuddy-gateway SSE 单帧(整段到达)
15
+ //
16
+ // 让它们各自造一套 id/revision 逻辑必然漂移,所以把**与引擎无关的部分**
17
+ // (item 身份、revision 计数、状态机)抽成本文件,各引擎持有一个实例。
18
+ //
19
+ // 分工边界(重要):
20
+ // itemTracker —— 身份与版本:item id、revision 递增、status 语义
21
+ // 调用方 —— 内容累积:claude 要按 content block index 升序拼接,
22
+ // 别的引擎直接 append 或整段替换
23
+ //
24
+ // 两个累积入口对应两种真实协议形态:
25
+ // Append(delta) 增量协议(claude delta / trae delta.content / llm chunk)
26
+ // Replace(full) 整段协议(arkclaw 与 gateway 一次给全文)——语义是
27
+ // **替换不是追加**,追加会得到「你好你好」
28
+
29
+ import "fmt"
30
+
31
+ // itemLane 同一轮内并行的内容通道。
32
+ //
33
+ // 为什么按 lane 拆开 item(2026-10-02 修正):
34
+ // thinking 与 text 共享一个 item id 时,消费方按 (item_id, revision)
35
+ // 覆盖式 upsert 会**丢掉先到的那一半**——
36
+ //
37
+ // thinking(snapshot=「我在想」, rev=1)
38
+ // text (snapshot=「答案」, rev=2) ← 覆盖,思考消失
39
+ //
40
+ // Text 字段是增量,所以终端直打完全看不出来;出问题的是走 snapshot
41
+ // 的消费方(历史回放、多端同步)—— 思考内容静默消失,且没有任何报错。
42
+ //
43
+ // agents-anywhere 面对同一问题选了同样的方向:reasoning 是**独立的
44
+ // system item**(runtime_protocol/timeline.py 的 SystemContentKind =
45
+ // "reasoning"),与 message 分属不同 TimelineItemType,压根不共用 item。
46
+ //
47
+ // 拆开不丢关联性:两个 item id 内嵌**同一个原生 message id**
48
+ // (claude_msg_<id> 与 claude_think_<id>),消费方要合并展示时
49
+ // 按原生 id 关联即可,不需要额外的映射表。
50
+ type itemLane string
51
+
52
+ const (
53
+ // laneText 正文通道,item id 形如 claude_msg_<native>。
54
+ laneText itemLane = ""
55
+
56
+ // laneThink 思考/推理通道,item id 形如 claude_think_<native>。
57
+ // 中段用 think_(不是 reason_):与 wire 上的事件 type=thinking 对齐,
58
+ // 读日志/grep 时不用在 reason / think / reasoning 之间猜。
59
+ laneThink itemLane = "think_"
60
+ )
61
+
62
+ // itemAttrs 一条事件的收敛属性。apply 把它写回 StreamEvent。
63
+ type itemAttrs struct {
64
+ ItemID string
65
+ Revision uint64
66
+ Status StreamStatus
67
+ Snapshot string
68
+ }
69
+
70
+ // apply 把收敛属性挂到事件上。
71
+ func (a itemAttrs) apply(ev *StreamEvent) {
72
+ ev.ItemID = a.ItemID
73
+ ev.ItemRevision = a.Revision
74
+ ev.Status = a.Status
75
+ ev.Snapshot = a.Snapshot
76
+ }
77
+
78
+ // itemTracker 维护「本轮当前 item」的身份与版本。
79
+ //
80
+ // 零值不可用,必须用 newItemTracker 构造。生命周期与一轮流式调用一致
81
+ // (每轮新建),所以不需要显式释放。
82
+ type itemTracker struct {
83
+ prefix string
84
+ itemID string
85
+ nativeID string
86
+ revision uint64
87
+ snapshot []byte
88
+ // localSeq 无原生 id 时的降级序号,**只增不减**。
89
+ //
90
+ // 为什么不复用 revision:revision 会被 Begin 归零,于是同一跟踪器里的
91
+ // 两条无 id 消息都会拿到 local_1 —— 后一条覆盖前一条(症状同
92
+ // 「一轮多条 assistant message 只剩最后一段」)。序号必须跨 Begin 单调。
93
+ localSeq uint64
94
+ }
95
+
96
+ // newItemTracker 构造某条 lane 的跟踪器。engine 决定 item id 前缀,
97
+ // lane 决定中段(不同引擎的原生 id 空间不互通,混用会让跨引擎回放撞 id)。
98
+ func newItemTracker(engine string, lane itemLane) *itemTracker {
99
+ return &itemTracker{prefix: itemTrackerPrefix(engine, lane)}
100
+ }
101
+
102
+ // itemTrackerPrefix 该引擎 + lane 的 item id 前缀。
103
+ func itemTrackerPrefix(engine string, lane itemLane) string {
104
+ base := "msg_"
105
+ if engine != "" {
106
+ base = engine + "_" + base
107
+ }
108
+ if lane == laneText {
109
+ return base
110
+ }
111
+ // 非正文通道插在中段:claude_msg_ → claude_think_
112
+ return engine + "_" + string(lane) + "msg_"
113
+ }
114
+
115
+ // Begin 声明「一条新消息开始了」。
116
+ //
117
+ // 什么时候必须调:claude 一轮里可能有**多条** assistant message
118
+ // (工具调用后继续对话,每次 tool_result 之后都是新的 message_start)。
119
+ // 若不切,一轮里的多条消息会共用一个 item id —— 前一条被后一条覆盖,
120
+ // 症状是「对话里只有最后一段回复,前面的不见了」。
121
+ //
122
+ // agents-anywhere 在 message_start 里 clear() 块桶并把 revision 归零
123
+ // (runtimes/claude/timeline/stream.py:35-36),本方法是对同一动作的封装。
124
+ //
125
+ // nativeID 为空时按序号降级(协议没给 message id)。
126
+ //
127
+ // 重复调用是安全的:带**同一个** native id 再 Begin 视为无操作,
128
+ // 不会清掉已累积的内容。某些实现在一条消息里会重复吐 message_start
129
+ // (codebuddy 实测有),照单重开会凭空截断正文。
130
+ func (t *itemTracker) Begin(nativeID string) {
131
+ if nativeID != "" && nativeID == t.nativeID {
132
+ return
133
+ }
134
+ t.itemID = ""
135
+ t.nativeID = nativeID
136
+ t.revision = 0
137
+ t.snapshot = t.snapshot[:0]
138
+ if nativeID != "" {
139
+ t.itemID = t.prefix + nativeID
140
+ }
141
+ }
142
+
143
+ // Note 记住引擎原生 id(拿不到就不给 item id——**不猜**)。
144
+ //
145
+ // 必须在第一个内容事件**之前**调:id 是先于内容出现的
146
+ // (claude 的 message_start、codex 的 item.started)。
147
+ // 之后仍可补记,届时会把已建立的降级 id 升级为原生形式。
148
+ func (t *itemTracker) Note(nativeID string) {
149
+ if nativeID == "" || t.nativeID == nativeID {
150
+ return
151
+ }
152
+ t.nativeID = nativeID
153
+ // 只在本轮还没发出任何事件时升级 item id:已经发过的帧挂在
154
+ // 降级 id 上,中途换 id 看起来像丢内容(消费方按 id 建表)。
155
+ if t.revision == 0 {
156
+ t.itemID = t.prefix + nativeID
157
+ }
158
+ }
159
+
160
+ // ensure 建立 item id(降级链见上:原生 id → 本轮内稳定序号)。
161
+ //
162
+ // 序号取自 localSeq(**不**取自 revision):revision 会被 Begin 归零,
163
+ // 拿它当序号会让同一跟踪器里的两条无 id 消息撞成同一个 local_1,
164
+ // 后一条把前一条覆盖掉。
165
+ func (t *itemTracker) ensure() string {
166
+ if t.itemID != "" {
167
+ return t.itemID
168
+ }
169
+ t.localSeq++
170
+ t.itemID = fmt.Sprintf("%slocal_%d", t.prefix, t.localSeq)
171
+ return t.itemID
172
+ }
173
+
174
+ // next 取下一个 revision 并返回收敛属性。
175
+ func (t *itemTracker) next(status StreamStatus) itemAttrs {
176
+ return t.nextWith(status, string(t.snapshot))
177
+ }
178
+
179
+ // nextWith 同 next,但调用方自己提供 snapshot 内容。
180
+ //
181
+ // 存在的理由:claude 的 snapshot 必须按 content block index 升序拼接
182
+ // (到达顺序不等于 index 顺序),由 streamAccumulator 算好后传进来 ——
183
+ // 身份与版本仍由跟踪器统一管理,内容拼接留给调用方(见文件头分工边界)。
184
+ func (t *itemTracker) nextWith(status StreamStatus, snapshot string) itemAttrs {
185
+ t.revision++
186
+ return itemAttrs{
187
+ ItemID: t.ensure(),
188
+ Revision: t.revision,
189
+ Status: status,
190
+ Snapshot: snapshot,
191
+ }
192
+ }
193
+
194
+ // Append 累积一段增量,返回该 item 的新版本属性(status=running)。
195
+ //
196
+ // 用于**增量协议**引擎:每帧把 delta 拼上去,snapshot 始终是累积整段。
197
+ func (t *itemTracker) Append(delta string) itemAttrs {
198
+ if delta != "" {
199
+ t.snapshot = append(t.snapshot, delta...)
200
+ }
201
+ return t.next(StatusRunning)
202
+ }
203
+
204
+ // Replace 用整段替换累积内容,返回新版本属性(status=running)。
205
+ //
206
+ // 用于**整段协议**引擎(arkclaw / codebuddy-gateway 一次给全文)。
207
+ // 语义必须是替换:这些引擎每帧给的是全文,追加会让同一份内容数两遍。
208
+ func (t *itemTracker) Replace(full string) itemAttrs {
209
+ t.snapshot = append(t.snapshot[:0], full...)
210
+ return t.next(StatusRunning)
211
+ }
212
+
213
+ // Finalize 收尾:把权威正文(引擎的 result 行)作为最后版本(status=final)。
214
+ //
215
+ // snapshot 用引擎给的权威值而非累积值 —— 增量拼接与 result 偶有出入
216
+ // (codebuddy 还要过 think 剥离)时以 result 为准。
217
+ //
218
+ // 没有建立过 item 时返回 ok=false:不该为「一句正文都没流出来」的轮次硬造 item。
219
+ func (t *itemTracker) Finalize(final string) (itemAttrs, bool) {
220
+ if t.itemID == "" {
221
+ return itemAttrs{}, false
222
+ }
223
+ if final != "" {
224
+ t.snapshot = append(t.snapshot[:0], final...)
225
+ return t.next(StatusFinal), true
226
+ }
227
+ // 没给权威值就沿用累积值,别把已有内容清空。
228
+ return t.next(StatusFinal), true
229
+ }
230
+
231
+ // Failed 异常收尾(status=failed):引擎流被截断,没走到终帧。
232
+ //
233
+ // 挂到已有 item 上:内容还在,只是这条 item 不会再增长。消费方看到
234
+ // failed 就知道不必再等。没建立过 item 时返回 ok=false。
235
+ //
236
+ // snapshot 形参用于 claude 那类「内容由调用方按 block 拼」的引擎 ——
237
+ // 跟踪器自己累积的那份是空的,传空串即可回退到它。
238
+ func (t *itemTracker) Failed(snapshot string) (itemAttrs, bool) {
239
+ if t.itemID == "" {
240
+ return itemAttrs{}, false
241
+ }
242
+ return t.nextWith(StatusFailed, snapshot), true
243
+ }
244
+
245
+ // trackerPair 是同一轮内并行的两条 lane 跟踪器。
246
+ //
247
+ // 单独这个类型是为了让「收尾」这类**跨 lane 操作**有一个明确归属。
248
+ // 注意这里只提供 text lane 的终态 —— 思考 lane **不单独发终态**:
249
+ //
250
+ // - final :result 行只含正文,给思考发 final 会让快照回退成空
251
+ // - failed :turn_failed 本身已是轮级终态信号
252
+ //
253
+ // 两条 lane 的终止由消费方「见到轮级终态后不再等思考」来表达,
254
+ // 详见 stream.go emitTurnEnd / emitTurnFailed 的注释。
255
+ type trackerPair struct {
256
+ text *itemTracker
257
+ think *itemTracker
258
+ }
259
+
260
+ // finalize 收尾正文 lane(final 状态,权威值 = result 行的正文)。
261
+ func (p trackerPair) finalize(final string) (itemAttrs, bool) {
262
+ return p.text.Finalize(final)
263
+ }
264
+
265
+ // failText 异常收尾正文 lane(failed 状态)。
266
+ //
267
+ // snapshot 形参由调用方提供:claude 的内容按 content block index 分桶
268
+ // 累积在累加器里,跟踪器自己那份是空的。
269
+ func (p trackerPair) failText(snapshot string) (itemAttrs, bool) {
270
+ return p.text.Failed(snapshot)
271
+ }