magic-agent 2.4.3 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -19,6 +19,7 @@ import (
19
19
  "path/filepath"
20
20
  "strings"
21
21
  "testing"
22
+ "time"
22
23
  )
23
24
 
24
25
  const (
@@ -30,6 +31,18 @@ const (
30
31
  limitNoticeZH = "429 您的使用量已超出频率限制,将在 2026-09-19 17:49:56 UTC+8 重置," +
31
32
  "您也可以切换其他模型继续使用。"
32
33
  limitNoticeNarration = "Let me read the file's end."
34
+ /* 实测英文额度通告(2026-10-25 用户报障截图里的那一轮原文,会话日志 01a16fea:
35
+ **整段只有它**、前面没有正文、带两个尾巴)。与上面那条 429 的差别是措辞:
36
+ 这条说的是「5 小时额度用尽」,上面那条是「频率限制」。 */
37
+ limitNoticeQuotaEN = "429 You have exceeded the 5-hour usage quota. It will reset at " +
38
+ "2026-10-25 15:07:27 +0800 CST. We recommend upgrading your plan for more quota, " +
39
+ "or waiting for the reset. Request id: 021792911321900fd0de7354b348b065f00b895e07126b865f18b" +
40
+ " (01a171d5e11f7519bcf4077dffd2ebad/01a16fea-4a01-789d-b694-3d99c11b3c8b)"
41
+ /* 实测 402 原文(同一批日志里连出七条):**没有恢复时刻**,却整段就是通告。
42
+ 它落在画面上就是「一大段供应商原文 + 已完成」—— 上面那条「有恢复时刻」的判据
43
+ 因此认不出它(2026-10-25 补的窄路就是为它)。 */
44
+ limitNoticeQuota402 = "402 当前已达到 Token Plan 用量上限。为避免调用中断,请升级 Token Plan 套餐," +
45
+ "或购买积分补充用量并开启积分自动消耗。 (2067) (01a16c13a2e67c05b09eb8b1311f97d8/01a16c05-cabd-7fd0-9fdc-abca97cb92ae)"
33
46
  )
34
47
 
35
48
  func TestSplitLimitNoticeRealSamples(t *testing.T) {
@@ -81,6 +94,43 @@ func TestSplitLimitNoticeRealSamples(t *testing.T) {
81
94
  }
82
95
  }
83
96
 
97
+ // 2026-10-25 加的两条实测原文:英文 5 小时额度、中文 402(都没在前面挂正文)。
98
+ func TestSplitLimitNoticeQuotaSamples(t *testing.T) {
99
+ notice, body, ok := splitLimitNotice(limitNoticeQuotaEN)
100
+ if !ok {
101
+ t.Fatalf("英文额度通告(截图里的那一轮)没认出来 —— 它会原样铺在气泡里")
102
+ }
103
+ if !strings.HasPrefix(notice, "429 You have exceeded") {
104
+ t.Errorf("notice = %q", notice)
105
+ }
106
+ if strings.Contains(notice, "01a171d5") || strings.Contains(notice, "Request id:") {
107
+ t.Errorf("notice = %q,尾巴应被剥掉(它会直接进卡片的原文行)", notice)
108
+ }
109
+ if body != "" {
110
+ t.Errorf("body = %q,want 空串(这一轮只回了通告)", body)
111
+ }
112
+
113
+ // 402 没有恢复时刻 → 走「整段就是通告」那条窄路。
114
+ notice, body, ok = splitLimitNotice(limitNoticeQuota402)
115
+ if !ok {
116
+ t.Fatalf("中文 402 原文没认出来(没有恢复时刻,靠窄路认)")
117
+ }
118
+ if !strings.HasPrefix(notice, "402 当前已达到") {
119
+ t.Errorf("notice = %q", notice)
120
+ }
121
+ if strings.Contains(notice, "2067") && !strings.Contains(notice, "积分") {
122
+ t.Errorf("notice = %q,尾巴 (2067) 应被剥掉", notice)
123
+ }
124
+ if body != "" {
125
+ t.Errorf("body = %q,want 空串", body)
126
+ }
127
+
128
+ // 402 前面挂着正文 → 不是「整段就是它」,不该整段吞掉(那条窄路只认 body 为空)。
129
+ if _, b, ok := splitLimitNotice("先跑了一遍测试。\n" + limitNoticeQuota402); ok && b != "先跑了一遍测试。" {
130
+ t.Errorf("正文被吞了:body = %q", b)
131
+ }
132
+ }
133
+
84
134
  func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
85
135
  cases := map[string]string{
86
136
  "科普句(无限流措辞)": "HTTP 429 是「请求过多」,服务端让你慢一点。",
@@ -89,6 +139,13 @@ func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
89
139
  "正文里出现过 429、后面还有别的话": "日志里出现了 429。先看限流配置,额度在 2026-10-11 重置。建议改用另一个模型。",
90
140
  "端口号不是状态码": "端口 8080 与 usage 有关。",
91
141
  "普通回答": "已按你的要求改完 3 个文件。",
142
+ /* 窄路(整段就是通告、无时刻)要挡住的几类(2026-10-25):
143
+ 「429 是限流,把额度用完就等它重置」= 模型在**回答**,措辞全中但没有一个字说
144
+ 「你此刻被拒了」。 */
145
+ "释义句(状态码在句首 + 表示)": "429 表示请求过多,额度用完时会自动重置,稍后再试即可。",
146
+ "释义句(中文「是」)": "402 是「需要付费」的意思,余额不足时你先升级套餐。",
147
+ /* 长一点的正常回答里提了额度:整段不短于 160 字 → 不是那条窄路。 */
148
+ "长回答里提额度": strings.Repeat("这一版把额度用完时的提示改成了卡片。", 12),
92
149
  }
93
150
  for name, in := range cases {
94
151
  if _, _, ok := splitLimitNotice(in); ok {
@@ -97,6 +154,89 @@ func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
97
154
  }
98
155
  }
99
156
 
157
+ // ── 分类与恢复时刻(2026-10-25,界面额度卡要的三项)────────────────────────
158
+
159
+ func TestClassifyNotice(t *testing.T) {
160
+ cases := []struct{ in, kind, code string }{
161
+ {limitNoticeQuotaEN, ErrorKindQuota, "429"},
162
+ {limitNoticeQuota402, ErrorKindQuota, "402"},
163
+ {limitNoticeZH, ErrorKindRateLimit, "429"}, // 「超出频率限制」→ 短时窗口
164
+ {limitNoticeEN, ErrorKindRateLimit, "429"},
165
+ {"", "", ""},
166
+ {"codebuddy CLI stream ended without result line", "", ""},
167
+ /* 402 即使没有一句措辞也要算额度:它是「需要付费」的语义码(原文可能只有
168
+ 「Payment Required」),归到普通错误卡会让人去查配置。 */
169
+ {"402 Payment Required", ErrorKindQuota, "402"},
170
+ }
171
+ for _, c := range cases {
172
+ kind, code := classifyNotice(c.in)
173
+ if kind != c.kind || code != c.code {
174
+ t.Errorf("classifyNotice(%q) = (%q,%q),want (%q,%q)", c.in, kind, code, c.kind, c.code)
175
+ }
176
+ }
177
+ }
178
+
179
+ func TestParseNoticeReset(t *testing.T) {
180
+ /* 三种拼法都是本仓库日志里的实测原文。**期望值是绝对时刻**,不看本机时区 ——
181
+ 带偏移的必须解成同一个瞬间(这就是不用宽松解析的理由:V8 会把 CST 当美国中部时间)。 */
182
+ cases := []struct{ in, want string }{
183
+ {limitNoticeQuotaEN, "2026-10-25T15:07:27+08:00"}, // +0800 无冒号,尾部还有 CST
184
+ {limitNoticeZH, "2026-09-19T17:49:56+08:00"}, // UTC+8 那种拼法
185
+ {limitNoticeEN, "2026-10-11T10:32:59+08:00"}, // UTC+8
186
+ {"will reset at 2026-10-25T07:07:27Z", "2026-10-25T15:07:27+08:00"}, // ISO 形状(Z = UTC)
187
+ {limitNoticeQuota402, ""}, // 没有时刻 → 空(客户端不编「稍后」)
188
+ {"已按你的要求改完 3 个文件。", ""},
189
+ }
190
+ for _, c := range cases {
191
+ got := noticeResetRFC3339(c.in)
192
+ if c.want == "" {
193
+ if got != "" {
194
+ t.Errorf("noticeResetRFC3339(%q) = %q,want 空", c.in, got)
195
+ }
196
+ continue
197
+ }
198
+ wt, err := time.Parse(time.RFC3339, c.want)
199
+ if err != nil {
200
+ t.Fatalf("测试自身的期望值写错了:%v", err)
201
+ }
202
+ gt, err := time.Parse(time.RFC3339, got)
203
+ if err != nil {
204
+ t.Errorf("noticeResetRFC3339(%q) = %q(不是 RFC3339)", c.in, got)
205
+ continue
206
+ }
207
+ if !gt.Equal(wt) {
208
+ t.Errorf("noticeResetRFC3339(%q) = %s,want 同一瞬间 %s", c.in, got, c.want)
209
+ }
210
+ }
211
+ }
212
+
213
+ // 收尾事件要带上三项结构化字段(界面靠它们决定画额度卡还是普通失败卡)。
214
+ func TestLimitNoticeEventCarriesFields(t *testing.T) {
215
+ var got *StreamEvent
216
+ a := &streamAccumulator{}
217
+ a.OnEvent = func(ev StreamEvent) { e := ev; got = &e }
218
+ a.emitLimitNoticeFailed("sid", strings.Join(strings.Fields(stripNoticeTails(limitNoticeQuotaEN)), " "), limitNoticeNarration)
219
+ if got == nil {
220
+ t.Fatal("没发事件")
221
+ }
222
+ if got.ErrorKind != ErrorKindQuota || got.ErrorCode != "429" {
223
+ t.Errorf("kind/code = %q/%q,want quota/429", got.ErrorKind, got.ErrorCode)
224
+ }
225
+ if got.ResetAt != "2026-10-25T15:07:27+08:00" {
226
+ t.Errorf("ResetAt = %q,want 2026-10-25T15:07:27+08:00", got.ResetAt)
227
+ }
228
+ if got.Text != limitNoticeNarration {
229
+ t.Errorf("Text = %q,want 剥掉通告的正文", got.Text)
230
+ }
231
+
232
+ // 402 那条:有 kind/code、**没有** reset(客户端据此不写「将于…恢复」)。
233
+ got = nil
234
+ a.emitLimitNoticeFailed("sid", strings.Join(strings.Fields(stripNoticeTails(limitNoticeQuota402)), " "), "")
235
+ if got == nil || got.ErrorKind != ErrorKindQuota || got.ErrorCode != "402" || got.ResetAt != "" {
236
+ t.Errorf("402 那条的 kind/code/reset = %+v", got)
237
+ }
238
+ }
239
+
100
240
  // codebuddyStreamWithResult 造一个假 codebuddy CLI:只吐一条给定正文的 result 行。
101
241
  // 正文里带单引号(英文通告的 don't)时没法直接写进 shell 脚本,所以落成文件再 cat。
102
242
  func codebuddyStreamWithResult(t *testing.T, text string) string {
@@ -94,13 +94,55 @@ func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string
94
94
  }
95
95
  if req.NoThinking && !LLMModelDisablesThinking(m) {
96
96
  // 没有同线 no-think 变体时,用 llm/OpenAI 兼容端点认的官方参数。
97
- // 若端点不支持该参数会返回 400 —— 这是显式 --disable-thinking 的预期失败,
98
- // 不静默退回「假装关闭」。
97
+ // 个别模型不接受 `none`(如 MiniMax-M3.1-Flash 要求 adaptive thinking,
98
+ // 端点回 400 / 2013)—— 那不是「不能少思考」,只是这个档位不被允许:
99
+ // 由 doConfiguredRequestThinkingAware 换成它允许的最低档重试一次。
99
100
  body["reasoning_effort"] = "none"
100
101
  }
101
102
  return body, nil
102
103
  }
103
104
 
105
+ // noThinkingFallbackEffort 是「模型不接受完全关闭思考」时退而使用的最低档。
106
+ //
107
+ // 2026-10-24 用户纠正过一处误判:「invalid params ... requires adaptive thinking;
108
+ // reasoning effort none is not allowed」**不代表模型不能少思考**,只是 `none` 这个
109
+ // 档位不被允许。实测 MiniMax-M3.1-Flash:`none` → 400;`minimal` → 200;
110
+ // `thinking.type=adaptive` → 200;`thinking.type=disabled` → 400。所以正确的做法是
111
+ // 换档重试,而不是把用户选的模型换掉、也不是让整条链路失败。
112
+ const noThinkingFallbackEffort = "minimal"
113
+
114
+ // rejectsNoThinking 判断错误是不是「这个模型不接受关闭思考的写法」。
115
+ // 认两条签名:`requires adaptive thinking` 与 `reasoning effort ... not allowed`;
116
+ // 其它 4xx(鉴权、模型不存在、额度…)一律原样返回,不瞎重试。
117
+ func rejectsNoThinking(err error) bool {
118
+ if err == nil {
119
+ return false
120
+ }
121
+ msg := strings.ToLower(err.Error())
122
+ if strings.Contains(msg, "requires adaptive thinking") {
123
+ return true
124
+ }
125
+ return strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "not allowed")
126
+ }
127
+
128
+ // doConfiguredRequestThinkingAware 发一次请求;若模型拒绝 `reasoning_effort=none`,
129
+ // 换成它允许的最低档(minimal)重试一次。
130
+ //
131
+ // 只在「我们主动塞了 none」时才换档:配置里显式声明的不思考变体
132
+ // (extraBody.thinking.type=disabled)走的是另一条路,端点拒绝它是配置错误,
133
+ // 换档只会把真正的原因盖住。
134
+ func doConfiguredRequestThinkingAware(ctx context.Context, m LLMConfigModel, req Request, body map[string]any) (*http.Response, error) {
135
+ resp, err := doConfiguredRequest(ctx, m, req, body)
136
+ if err == nil || !rejectsNoThinking(err) {
137
+ return resp, err
138
+ }
139
+ if !req.NoThinking || LLMModelDisablesThinking(m) {
140
+ return nil, err
141
+ }
142
+ body["reasoning_effort"] = noThinkingFallbackEffort
143
+ return doConfiguredRequest(ctx, m, req, body)
144
+ }
145
+
104
146
  // attachToLastUserMessage 把附件塞进最后一条 user 消息的 content:
105
147
  //
106
148
  // 图片 → {"type":"image_url","image_url":{"url":"data:<mime>;base64,<...>"}}(在前)
@@ -207,7 +249,7 @@ func (e *LLMEngine) completeConfigured(ctx context.Context, m LLMConfigModel, re
207
249
  }
208
250
  cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
209
251
  defer cancel()
210
- resp, err := doConfiguredRequest(cctx, m, req, body)
252
+ resp, err := doConfiguredRequestThinkingAware(cctx, m, req, body)
211
253
  if err != nil {
212
254
  return Response{}, err
213
255
  }
@@ -309,7 +351,7 @@ func (e *LLMEngine) streamConfigured(ctx context.Context, m LLMConfigModel, req
309
351
  }
310
352
  cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
311
353
  defer cancel()
312
- resp, err := doConfiguredRequest(cctx, m, req, body)
354
+ resp, err := doConfiguredRequestThinkingAware(cctx, m, req, body)
313
355
  if err != nil {
314
356
  return StreamResult{}, err
315
357
  }
@@ -20,6 +20,7 @@ import (
20
20
  "os"
21
21
  "path/filepath"
22
22
  "strings"
23
+ "sync"
23
24
  "testing"
24
25
  "time"
25
26
  )
@@ -301,6 +302,162 @@ func TestBuildConfiguredBodyNoThinking(t *testing.T) {
301
302
  }
302
303
  }
303
304
 
305
+ // 2026-10-24 用户纠正的判定:「invalid params ... requires adaptive thinking;
306
+ // reasoning effort none is not allowed」**不代表模型不能少思考**,只是 `none` 这个档位
307
+ // 不被允许(实测 MiniMax-M3.1-Flash:none → 400,minimal / thinking.type=adaptive → 200)。
308
+ // 所以应当**换档重试**,而不是把用户选的模型换掉、也不是让整条链路失败。
309
+ func TestConfiguredNoThinkingFallsBackToAllowedEffort(t *testing.T) {
310
+ var mu sync.Mutex
311
+ var bodies []map[string]any
312
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313
+ raw, _ := io.ReadAll(r.Body)
314
+ var b map[string]any
315
+ _ = json.Unmarshal(raw, &b)
316
+ mu.Lock()
317
+ bodies = append(bodies, b)
318
+ mu.Unlock()
319
+ if b["reasoning_effort"] == "none" {
320
+ w.WriteHeader(http.StatusBadRequest)
321
+ fmt.Fprint(w, `{"type":"error","error":{"type":"bad_request_error",`+
322
+ `"message":"invalid params, model \"MiniMax-M3.1-Flash\" requires adaptive thinking; `+
323
+ `reasoning effort none is not allowed (2013)","http_code":"400"}}`)
324
+ return
325
+ }
326
+ fmt.Fprint(w, `{"choices":[{"message":{"content":"处理过期 token","reasoning_content":"内部思路"}}]}`)
327
+ }))
328
+ defer srv.Close()
329
+ writeModelsJSON(t, []map[string]any{
330
+ {"id": "MiniMax-M3.1-Flash", "url": srv.URL + "/v1", "apiKey": "k"},
331
+ })
332
+
333
+ resp, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
334
+ Engine: "llm", Model: "MiniMax-M3.1-Flash", NoThinking: true,
335
+ Messages: []Message{{Role: "user", Content: "hi"}},
336
+ })
337
+ if err != nil {
338
+ t.Fatalf("拒绝 none 时应换档重试而不是报错:%v", err)
339
+ }
340
+ if resp.Text != "处理过期 token" {
341
+ t.Errorf("正文应正常返回,得 %q", resp.Text)
342
+ }
343
+ mu.Lock()
344
+ defer mu.Unlock()
345
+ if len(bodies) != 2 {
346
+ t.Fatalf("应请求两次(none 失败 → minimal 重试),得 %d 次", len(bodies))
347
+ }
348
+ if bodies[0]["reasoning_effort"] != "none" {
349
+ t.Errorf("第一次应仍按原口径传 none,得 %v", bodies[0]["reasoning_effort"])
350
+ }
351
+ if bodies[1]["reasoning_effort"] != noThinkingFallbackEffort {
352
+ t.Errorf("重试应换成 %q,得 %v", noThinkingFallbackEffort, bodies[1]["reasoning_effort"])
353
+ }
354
+ // 关键:换的是档位,不是模型 —— 用户选的就是这一条配置。
355
+ if bodies[1]["model"] != "MiniMax-M3.1-Flash" {
356
+ t.Errorf("不该换模型,得 %v", bodies[1]["model"])
357
+ }
358
+ }
359
+
360
+ // 与「关闭思考」无关的 4xx(鉴权 / 模型不存在…)必须原样报错,不许瞎重试。
361
+ func TestConfiguredBadRequestUnrelatedNotRetried(t *testing.T) {
362
+ var mu sync.Mutex
363
+ calls := 0
364
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
365
+ mu.Lock()
366
+ calls++
367
+ mu.Unlock()
368
+ w.WriteHeader(http.StatusBadRequest)
369
+ fmt.Fprint(w, `{"error":{"message":"model not found"}}`)
370
+ }))
371
+ defer srv.Close()
372
+ writeModelsJSON(t, []map[string]any{{"id": "m1", "url": srv.URL + "/v1", "apiKey": "k"}})
373
+
374
+ _, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
375
+ Engine: "llm", Model: "m1", NoThinking: true,
376
+ Messages: []Message{{Role: "user", Content: "hi"}},
377
+ })
378
+ if err == nil || !strings.Contains(err.Error(), "HTTP 400") {
379
+ t.Fatalf("无关的 400 应原样报错,得 %v", err)
380
+ }
381
+ mu.Lock()
382
+ defer mu.Unlock()
383
+ if calls != 1 {
384
+ t.Errorf("无关错误不该重试,实际请求 %d 次", calls)
385
+ }
386
+ }
387
+
388
+ // 配置里显式声明的不思考变体(extraBody.thinking.type=disabled)被端点拒绝时,
389
+ // 那是**配置错误** —— 不许把它悄悄改成 minimal 把真实原因盖住。
390
+ func TestConfiguredDisabledVariantErrorNotRewritten(t *testing.T) {
391
+ var mu sync.Mutex
392
+ calls := 0
393
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394
+ mu.Lock()
395
+ calls++
396
+ mu.Unlock()
397
+ w.WriteHeader(http.StatusBadRequest)
398
+ fmt.Fprint(w, `{"error":{"message":"model \"x\" requires adaptive thinking; `+
399
+ `thinking.type=\"disabled\" (including reasoning.effort=none) is not allowed (2013)"}}`)
400
+ }))
401
+ defer srv.Close()
402
+ writeModelsJSON(t, []map[string]any{
403
+ {"id": "x-nothink", "model": "x", "url": srv.URL + "/v1", "apiKey": "k",
404
+ "extraBody": map[string]any{"thinking": map[string]any{"type": "disabled"}}},
405
+ })
406
+
407
+ _, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
408
+ Engine: "llm", Model: "x-nothink", NoThinking: true,
409
+ Messages: []Message{{Role: "user", Content: "hi"}},
410
+ })
411
+ if err == nil || !strings.Contains(err.Error(), "is not allowed") {
412
+ t.Fatalf("配置错误应如实报出,得 %v", err)
413
+ }
414
+ mu.Lock()
415
+ defer mu.Unlock()
416
+ if calls != 1 {
417
+ t.Errorf("配置声明的不思考变体不该被改写重试,实际请求 %d 次", calls)
418
+ }
419
+ }
420
+
421
+ // 流式路径同样要换档重试(两条路径共用 doConfiguredRequestThinkingAware)。
422
+ func TestStreamConfiguredNoThinkingFallsBack(t *testing.T) {
423
+ var mu sync.Mutex
424
+ var bodies []map[string]any
425
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
426
+ raw, _ := io.ReadAll(r.Body)
427
+ var b map[string]any
428
+ _ = json.Unmarshal(raw, &b)
429
+ mu.Lock()
430
+ bodies = append(bodies, b)
431
+ mu.Unlock()
432
+ if b["reasoning_effort"] == "none" {
433
+ w.WriteHeader(http.StatusBadRequest)
434
+ fmt.Fprint(w, `{"error":{"message":"requires adaptive thinking; reasoning effort none is not allowed (2013)"}}`)
435
+ return
436
+ }
437
+ w.Header().Set("Content-Type", "text/event-stream")
438
+ fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n")
439
+ fmt.Fprint(w, "data: [DONE]\n\n")
440
+ }))
441
+ defer srv.Close()
442
+ writeModelsJSON(t, []map[string]any{{"id": "MiniMax-M3.1-Flash", "url": srv.URL + "/v1", "apiKey": "k"}})
443
+
444
+ res, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Stream(context.Background(), Request{
445
+ Engine: "llm", Model: "MiniMax-M3.1-Flash", NoThinking: true,
446
+ Messages: []Message{{Role: "user", Content: "hi"}},
447
+ }, nil)
448
+ if err != nil {
449
+ t.Fatalf("流式也应在换档后成功:%v", err)
450
+ }
451
+ if res.Response.Text != "好" {
452
+ t.Errorf("正文应为「好」,得 %q", res.Response.Text)
453
+ }
454
+ mu.Lock()
455
+ defer mu.Unlock()
456
+ if len(bodies) != 2 || bodies[1]["reasoning_effort"] != noThinkingFallbackEffort {
457
+ t.Errorf("流式路径也应换档重试,实际 %d 次、第二次档位 %v", len(bodies), bodies[1]["reasoning_effort"])
458
+ }
459
+ }
460
+
304
461
  func TestExpandEnvKeep(t *testing.T) {
305
462
  t.Setenv("LLM_TEST_KEY", "secret")
306
463
  cases := map[string]string{
@@ -11,7 +11,7 @@ package agent
11
11
  // codebuddy 扩展来源链(两端同链 ③):客户端合并配置缓存 acc-product-config
12
12
  // → 远程配置缓存 ∪ App 包 product.json → `codebuddy --help` 的
13
13
  // "Currently supported: (...)" 清单(末级兜底)
14
- // trae `trae-cli models --json`
14
+ // trae `traex models --json`
15
15
  // llm `llm models`(用户经 llm CLI 自注册的模型)
16
16
  // codex `codex debug models`(raw model catalog)
17
17
  // openclaw `openclaw models list --json`
@@ -94,6 +94,22 @@ func ModelCreditListOf(e Engine) ModelCreditLister {
94
94
  return l
95
95
  }
96
96
 
97
+ // ModelVendorLister 可选接口:引擎能给出各模型的**供应商展示名**
98
+ // (只用于选择器的第二行,不参与 -m / 落库)。键与 ListModels 的模型 id 同形;
99
+ // 拿不到(或引擎不提供)返回 nil —— 界面不显示第二行。
100
+ //
101
+ // 现状只有 codebuddy 系实现:值取自它自己 models.json 里 `vendor` 字段
102
+ // (空 / "Custom" 这类无信息量的值会被过滤,见 CLIModelVendors)。
103
+ type ModelVendorLister interface {
104
+ ModelVendors(ctx context.Context) map[string]string
105
+ }
106
+
107
+ // ModelVendorListOf 返回引擎的 ModelVendorLister 实现(未实现返回 nil)。
108
+ func ModelVendorListOf(e Engine) ModelVendorLister {
109
+ l, _ := e.(ModelVendorLister)
110
+ return l
111
+ }
112
+
97
113
  // parseCreditMultiplier 把客户端计费字段规范化成纯倍率数字字符串:
98
114
  //
99
115
  // "x2.20 credits" -> "2.20" "x0.77" -> "0.77" " X1.5 CREDITS " -> "1.5"
@@ -511,6 +527,42 @@ func remoteConfigCacheCatalog(localStorageDir string) ([]string, map[string]stri
511
527
  return ids, credits
512
528
  }
513
529
 
530
+ // remoteCacheCatalogWithSelector 用「本账号 CLI 自己下发的远程配置缓存」当注册表,
531
+ // 并用**同一目录**里的客户端选择器清单(agents[].models)过滤,产出与 acc 缓存
532
+ // 同语义的模型清单 + 倍率表。
533
+ //
534
+ // 为什么需要它:codebuddy-ai 的账号已与桌面 App 物理隔离(独立 homedir),App 的
535
+ // acc 缓存(~/.workbuddy-ai)属于另一个账号、只有 5 条模型,会把清单钉死在 2 个
536
+ // custom-local。隔离后的引擎应改用 CLI 自己拉到的配置(<config_dir>/local_storage)。
537
+ //
538
+ // 过滤语义与 readAccCatalog 一致:custom-local:* 一律保留(客户端也展示,只是不计
539
+ // 倍率),具名模型只在选择器清单里才展示;缓存里没有选择器清单时不过滤(宁可多不可少)。
540
+ func remoteCacheCatalogWithSelector(localStorageDir string) ([]string, map[string]string) {
541
+ ids, credits := remoteConfigCacheCatalog(localStorageDir)
542
+ if len(ids) == 0 {
543
+ return nil, nil
544
+ }
545
+ allow, ok := agentSelectorModels(localStorageDir)
546
+ if !ok || len(allow) == 0 {
547
+ return ids, credits
548
+ }
549
+ on := make(map[string]bool, len(allow))
550
+ for _, id := range allow {
551
+ on[id] = true
552
+ }
553
+ kept := make([]string, 0, len(ids))
554
+ for _, id := range ids {
555
+ if strings.HasPrefix(id, "custom-local:") || on[id] {
556
+ kept = append(kept, id)
557
+ }
558
+ }
559
+ if len(kept) == 0 {
560
+ // 选择器清单与注册表完全对不上(配置漂移):退回全量,别给出空清单。
561
+ return ids, credits
562
+ }
563
+ return kept, credits
564
+ }
565
+
514
566
  // agentSelectorModels 从远程配置缓存里抽**客户端模型选择器真正展示的清单**
515
567
  // —— agents[].models 数组:
516
568
  //
@@ -157,7 +157,10 @@ func TestJSONModelsArray(t *testing.T) {
157
157
  func TestEngineListModelsWithFakeCLI(t *testing.T) {
158
158
  // 隔离 HOME:codebuddy-ai 的扩展来源会扫 ~/.codebuddy-ai 与 ~/.codebuddy 的
159
159
  // local_storage 缓存,不隔离会吃到真机缓存导致断言失败。
160
- t.Setenv("HOME", t.TempDir())
160
+ // Windows 上 os.UserHomeDir() 读 USERPROFILE,两者都要设。
161
+ home := t.TempDir()
162
+ t.Setenv("HOME", home)
163
+ t.Setenv("USERPROFILE", home)
161
164
  // 同一道理,CODEBUDDY_CONFIG_DIR 一设,「只信该目录」的规则就生效,
162
165
  // 本用例凭空搭的 HOME 缓存被绕过(见 TestCodeBuddyListModelsProductJSON 的注释)。
163
166
  t.Setenv("CODEBUDDY_CONFIG_DIR", "")
@@ -242,7 +245,9 @@ func TestEngineListModelsWithFakeCLI(t *testing.T) {
242
245
  func TestEngineListModelsFailures(t *testing.T) {
243
246
  // 隔离 HOME 与配置目录:codebuddy 的清单现在先读扩展来源(acc 缓存 /
244
247
  // 远程配置缓存),不隔离会吃到真机的 ~/.workbuddy 缓存而"意外成功"。
245
- t.Setenv("HOME", t.TempDir())
248
+ home := t.TempDir()
249
+ t.Setenv("HOME", home)
250
+ t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir 读 USERPROFILE
246
251
  t.Setenv("CODEBUDDY_CONFIG_DIR", filepath.Join(t.TempDir(), "cfg"))
247
252
 
248
253
  // 三级来源全空、--help 里也没有 "Currently supported" → ErrNoModelSource 语义的错误。
@@ -389,7 +394,10 @@ func TestReadRemoteConfigCacheModels(t *testing.T) {
389
394
  // codebuddy-ai 首选 ~/.workbuddy-ai/cache/acc-product-config-v*.json(客户端模型
390
395
  // 选择器同源);多版本取 mtime 最新;缺失时回退「远程配置缓存 ∪ product.json」。
391
396
  func TestCodeBuddyListModelsAccConfig(t *testing.T) {
392
- t.Setenv("HOME", t.TempDir())
397
+ home := t.TempDir()
398
+ // Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机缓存。
399
+ t.Setenv("HOME", home)
400
+ t.Setenv("USERPROFILE", home)
393
401
  cacheDir := filepath.Join(os.Getenv("HOME"), ".workbuddy-ai", "cache")
394
402
  if err := os.MkdirAll(cacheDir, 0o755); err != nil {
395
403
  t.Fatal(err)
@@ -488,7 +496,10 @@ func TestCodeBuddyListModelsAccConfig(t *testing.T) {
488
496
  // 2026-09-28 起 codebuddy(WorkBuddy 端)也启用扩展链,故 D 段与 AI 端同构。
489
497
  func TestCodeBuddyListModelsProductJSON(t *testing.T) {
490
498
  // 隔离 HOME:扩展来源扫 ~/.codebuddy-ai 与 ~/.codebuddy 的缓存,不隔离会吃到真机缓存。
491
- t.Setenv("HOME", t.TempDir())
499
+ // Windows 上 os.UserHomeDir() 读 USERPROFILE,两者都要设。
500
+ home := t.TempDir()
501
+ t.Setenv("HOME", home)
502
+ t.Setenv("USERPROFILE", home)
492
503
  /* 还要清 CODEBUDDY_CONFIG_DIR:用户显式设了它时,`codebuddyModelCacheDirs` 只返回
493
504
  该目录下的 local_storage、**不再兜底共享 ~/.codebuddy**(产品行为是对的:显式
494
505
  配置就该独占)。但本用例全程用 HOME 凭空搭缓存,环境里一带这个变量,B/D2 段
@@ -646,7 +657,10 @@ func TestCodeBuddyListModelsProductJSON(t *testing.T) {
646
657
  // 此前它只按 --help 出清单、倍率却按 acc 缓存取,同一引擎两个字段读自两个文件)。
647
658
  // 同时验证清单与倍率全程不启动 CLI(acc 命中时只读缓存文件)。
648
659
  func TestCodeBuddyModelCredits(t *testing.T) {
649
- t.Setenv("HOME", t.TempDir())
660
+ home := t.TempDir()
661
+ // Windows 上 os.UserHomeDir() 读 USERPROFILE,只设 HOME 隔离不住真机缓存。
662
+ t.Setenv("HOME", home)
663
+ t.Setenv("USERPROFILE", home)
650
664
  t.Setenv("CODEBUDDY_CONFIG_DIR", "")
651
665
 
652
666
  const help = `Options:
@@ -777,6 +791,7 @@ func appPkgCLI(t *testing.T, helpOut, productJSON string) (bin, argsLog string)
777
791
  func TestClaudeListModelsFromConfig(t *testing.T) {
778
792
  home := t.TempDir()
779
793
  t.Setenv("HOME", home)
794
+ t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir 读 USERPROFILE
780
795
  t.Setenv("CLAUDE_CONFIG_DIR", "")
781
796
 
782
797
  dir := filepath.Join(home, ".claude")
@@ -0,0 +1,40 @@
1
+ //go:build windows
2
+
3
+ package agent
4
+
5
+ import (
6
+ "os"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ // PIDAlive 的两条硬要求(2026-10-24「返回会话列表很忙」的回归闸门):
12
+ //
13
+ // ① 判得准:自己活着、不存在的 pid 说不在;
14
+ // ② **不许起子进程** —— 原实现 `tasklist /FI "PID eq N"` 本机实测每次 6.7–7.5 秒,
15
+ // `--sessions` 对每条活着的记录问一次 ⇒ 列表 5–10 秒。这里用 50 次调用的总耗时
16
+ // 卡住回归:走 OpenProcess 是微秒级,一旦退回起进程,这条会**红**(而不是悄悄变慢)。
17
+ func TestPIDAliveSelfAndBogus(t *testing.T) {
18
+ if !PIDAlive(os.Getpid()) {
19
+ t.Fatal("自己的 pid 应判为活着")
20
+ }
21
+ if PIDAlive(1 << 30) {
22
+ t.Fatal("不存在的 pid 应判为不在")
23
+ }
24
+ if PIDAlive(0) || PIDAlive(-1) {
25
+ t.Fatal("非正 pid 应判为不在")
26
+ }
27
+ }
28
+
29
+ func TestPIDAliveIsFast(t *testing.T) {
30
+ self := os.Getpid()
31
+ st := time.Now()
32
+ for i := 0; i < 50; i++ {
33
+ if !PIDAlive(self) {
34
+ t.Fatal("自己的 pid 应判为活着")
35
+ }
36
+ }
37
+ if d := time.Since(st); d > 2*time.Second {
38
+ t.Fatalf("50 次 PIDAlive 用了 %v —— 又回到起子进程(tasklist)的实现了吗?", d)
39
+ }
40
+ }
@@ -51,6 +51,22 @@ func notifySpawn(ctx context.Context, pid int) {
51
51
  }
52
52
  }
53
53
 
54
+ // ConfigureProcGroup 让子进程进入**独立进程组**(供本仓之外的调用方使用)。
55
+ //
56
+ // 目前是 internal/client 的脚本管理(见 scripts.go):脚本被启动后要能被
57
+ // **整组**停掉 —— unix 上 kill(-pid) 一把带走全部后代,Windows 上 taskkill /T
58
+ // 带走整棵进程树。若不设这一层,脚本 spawn 出来的 worker 会变孤儿进程,
59
+ // 停脚本只停掉了壳。
60
+ //
61
+ // 各平台的实现分别在 runcmd_unix.go(Setpgid)与 runcmd_windows.go
62
+ // (CREATE_NEW_PROCESS_GROUP)。
63
+ func ConfigureProcGroup(cmd *exec.Cmd) {
64
+ if cmd == nil {
65
+ return
66
+ }
67
+ configureProcAttr(cmd)
68
+ }
69
+
54
70
  // runCLI 在独立进程组里执行 bin args,返回 (stdout, stderr, err)。
55
71
  // 超时/取消时杀掉整个进程组(含 CLI 的全部后代进程)。
56
72
  func runCLI(ctx context.Context, bin string, args ...string) (string, string, error) {
@@ -73,6 +89,13 @@ func runCLIEnv(ctx context.Context, extra []string, bin string, args ...string)
73
89
 
74
90
  // runCLIEnvIn = runCLIIn + runCLIEnv(工作目录 + 追加环境变量)。
75
91
  func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, args ...string) (string, string, error) {
92
+ return runCLIEnvInStdin(ctx, dir, extra, "", bin, args...)
93
+ }
94
+
95
+ // runCLIEnvInStdin 同 runCLIEnvIn,额外把 stdinText 喂给子进程 stdin(空 = 保持旧行为,
96
+ // stdin 是空的)。dsh 的 headless 在 Windows 上只能用这条路传多行提示词(见 dsh.go::
97
+ // dshHeadlessPromptArg),其它引擎都不用。
98
+ func runCLIEnvInStdin(ctx context.Context, dir string, extra []string, stdinText, bin string, args ...string) (string, string, error) {
76
99
  // launchWrap 与流式路径同源:.cmd/.bat 在 Windows 上不能直接 exec,
77
100
  // 参数会被 cmd.exe 拆坏(实测含换行的提示词整段丢失)。unix 上原样透传。
78
101
  spec := launchWrap(bin, args)
@@ -80,6 +103,9 @@ func runCLIEnvIn(ctx context.Context, dir string, extra []string, bin string, ar
80
103
  if dir != "" {
81
104
  cmd.Dir = dir
82
105
  }
106
+ if stdinText != "" {
107
+ cmd.Stdin = strings.NewReader(stdinText)
108
+ }
83
109
  configureProcAttr(cmd)
84
110
 
85
111
  /* stdout/stderr 用**自建管道 + 自己 drain**,不直挂 bytes.Buffer: