magic-agent 1.0.2 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// launch.go - 跨平台的「怎么把引擎进程拉起来」包装层(平台无关部分)。
|
|
4
|
+
//
|
|
5
|
+
// ## 为什么需要这一层
|
|
6
|
+
//
|
|
7
|
+
// 引擎 CLI 有两种落地形态:
|
|
8
|
+
//
|
|
9
|
+
// npm 全局装的 shim:Windows 上是 claude.cmd / codebuddy.cmd(批处理),
|
|
10
|
+
// macOS/Linux 上是 claude / codebuddy(无扩展名的脚本)
|
|
11
|
+
// 独立安装的原生二进制:trae-cli.exe / dsh 等
|
|
12
|
+
//
|
|
13
|
+
// **批处理不能被直接 exec**。Windows 的 exec.Command 遇到 `.cmd` 会走
|
|
14
|
+
// cmd.exe 解析层,参数里的非 ASCII 字符会被代码页转换吃掉(中文 → 乱码),
|
|
15
|
+
// `&` `|` `^` `%` 会被当命令语法解析,子进程拿不到原始输入。
|
|
16
|
+
//
|
|
17
|
+
// 实测的真凶(2026-10-01,对照矩阵,提示词含换行+中文+%&^+引号):
|
|
18
|
+
//
|
|
19
|
+
// 裸 claude.exe 直连 → 位置参数、stdin 两条路都完好无损
|
|
20
|
+
// claude.cmd 直连 + 位置参数 → 中文乱码、`&` 触发「'香蕉' 不是内部或外部命令」
|
|
21
|
+
// claude.cmd 直连 + stdin → 完好(stdin 走字节流,不过参数解析层)
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ 换行**不是**元凶(.cmd 下 LF/CRLF 都正常)。换行只是最容易被肉眼
|
|
24
|
+
// 发现的触发点,真正破坏内容的是编码转换与元字符解析。
|
|
25
|
+
//
|
|
26
|
+
// agents-anywhere 对同一问题的处理见
|
|
27
|
+
// `anywhere-labs/Agents-Anywhere` 的 `connector/connector/launch.py:48-56`:
|
|
28
|
+
// `.cmd`/`.bat` 一律不直接 exec,改包成
|
|
29
|
+
// `powershell -NoProfile -ExecutionPolicy Bypass -Command "& 'path' 'arg1' …"`,
|
|
30
|
+
// 参数用**单引号**转义(单引号写两个)。本文件是它的 Go 等价物。
|
|
31
|
+
//
|
|
32
|
+
// ## 本层解决什么、不解决什么
|
|
33
|
+
//
|
|
34
|
+
// 解决:**引号层面的**参数传递(launchWrap 只在 Windows 上生效,
|
|
35
|
+
// unix 直连,行为零变化)。
|
|
36
|
+
//
|
|
37
|
+
// 不解决:**长度/结构层面**的提示词传递 —— 提示词不该当位置参数传。
|
|
38
|
+
// 那是引擎各自的选择,见 claude.go / codebuddy.go 的「提示词走 stdin」。
|
|
39
|
+
// 两层是互补的:stdin 解决内容,launchWrap 解决剩下的 flag 传递。
|
|
40
|
+
|
|
41
|
+
import (
|
|
42
|
+
"path/filepath"
|
|
43
|
+
"strings"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// launchSpec 描述一次「最终怎么 exec」的决策结果。
|
|
47
|
+
type launchSpec struct {
|
|
48
|
+
// bin 真正要交给 exec.Command 的可执行文件。
|
|
49
|
+
bin string
|
|
50
|
+
// args 真正要交给 exec.Command 的参数(已按 launcher 变形)。
|
|
51
|
+
args []string
|
|
52
|
+
// wrapped 标记是否经过了 PowerShell 包装(诊断用;false = 原样直连)。
|
|
53
|
+
wrapped bool
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// windowsBatchExt 是 Windows 上**不能直接 exec** 的脚本后缀。
|
|
57
|
+
//
|
|
58
|
+
// `.ps1` 需要 ExecutionPolicy Bypass 才能跑,`.cmd`/`.bat` 需要 cmd.exe
|
|
59
|
+
// 解析层 —— 两者都要包一层 PowerShell 才安全。
|
|
60
|
+
var windowsBatchExt = map[string]bool{
|
|
61
|
+
".cmd": true,
|
|
62
|
+
".bat": true,
|
|
63
|
+
".ps1": true,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// needsPowerShellWrap 报告这个可执行文件在当前平台是否需要 PowerShell 包装。
|
|
67
|
+
//
|
|
68
|
+
// 判定依据是**后缀**而非「能不能执行」:npm 装的 shim 在 Windows 上
|
|
69
|
+
// 一定是 .cmd,即便 PATH 里 shim 指向的可能是别的东西。
|
|
70
|
+
func needsPowerShellWrap(bin string) bool {
|
|
71
|
+
if !platformIsWindows() {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
return windowsBatchExt[strings.ToLower(filepath.Ext(bin))]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// launchWrap 按平台把「引擎可执行文件 + 它的参数」变成可直接 exec 的形式。
|
|
78
|
+
//
|
|
79
|
+
// unix:原样返回(零行为变化)。
|
|
80
|
+
// Windows + 原生 exe:原样返回。
|
|
81
|
+
// Windows + .cmd/.bat/.ps1:包成
|
|
82
|
+
//
|
|
83
|
+
// powershell -NoProfile -ExecutionPolicy Bypass -Command
|
|
84
|
+
// "& 'C:\path\claude.cmd' 'arg1' 'arg2'"
|
|
85
|
+
//
|
|
86
|
+
// 空串 flag 由调用方通过 emptyStringFlags 声明(见 emptyArgAsFlagValue 的说明)。
|
|
87
|
+
//
|
|
88
|
+
// ⚠️ 为什么不用 cmd /c 包一层(更"原生"的做法):cmd 的引号规则在参数里
|
|
89
|
+
// 出现时无法可靠转义 —— % 是变量展开、^ 是转义符、& 是命令分隔符,
|
|
90
|
+
// 提示词里任何一个都可能踩到。PowerShell 的单引号字符串是字面量,
|
|
91
|
+
// 唯一的转义就是把单引号写成两个,语义上没有二义性。这与 agents-anywhere 的
|
|
92
|
+
// launch.py 选 powershell 而非 cmd 是同一个理由。
|
|
93
|
+
func launchWrap(bin string, args []string) launchSpec {
|
|
94
|
+
if !platformIsWindows() {
|
|
95
|
+
return launchSpec{bin: bin, args: args}
|
|
96
|
+
}
|
|
97
|
+
// 先把「flag + 空串」改写成 flag=,再进包装 —— 顺序不能反:
|
|
98
|
+
// 包装后参数已成 PowerShell 脚本文本,空串已无处安放。
|
|
99
|
+
if len(emptyStringFlags) > 0 {
|
|
100
|
+
for _, f := range emptyStringFlags {
|
|
101
|
+
args = emptyArgAsFlagValue(args, f)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if !needsPowerShellWrap(bin) {
|
|
105
|
+
return launchSpec{bin: bin, args: args}
|
|
106
|
+
}
|
|
107
|
+
script := powershellInvokeScript(bin, args)
|
|
108
|
+
return launchSpec{
|
|
109
|
+
bin: powershellBin(),
|
|
110
|
+
args: []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script},
|
|
111
|
+
wrapped: true,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// emptyStringFlags 全局登记:本项目里哪些 flag 会以**空串**作为值。
|
|
116
|
+
//
|
|
117
|
+
// 为什么要全局而不是按引擎传:改写发生在 launchWrap 这一层,
|
|
118
|
+
// 而引擎各自的 buildArgs 只知道「我要传 --tools 空串」,
|
|
119
|
+
// 不该知道「Windows 上这么传会丢」。分层就在这里 —— 引擎管语义,
|
|
120
|
+
// launch 管平台怎么落地。
|
|
121
|
+
//
|
|
122
|
+
// ⚠️ 这是一张**白名单**,不是「所有空串」的通用规则:
|
|
123
|
+
// 未来新增「用空串表达含义」的 flag 时必须登记进来,否则在 Windows 上
|
|
124
|
+
// 会静默丢参数(症状:该 flag 被 CLI 忽略,行为退回默认)。
|
|
125
|
+
var emptyStringFlags = []string{"--tools"}
|
|
126
|
+
|
|
127
|
+
// powershellInvokeScript 拼出 PowerShell 的调用脚本:
|
|
128
|
+
// `& '脚本路径' '参数1' '参数2' …`
|
|
129
|
+
//
|
|
130
|
+
// & 是 PowerShell 的调用运算符("执行这个"),必须保留;
|
|
131
|
+
// 路径与每个参数都用单引号包起来,单引号自身写成两个。
|
|
132
|
+
func powershellInvokeScript(bin string, args []string) string {
|
|
133
|
+
parts := make([]string, 0, len(args)+1)
|
|
134
|
+
parts = append(parts, "&", powershellQuote(bin))
|
|
135
|
+
for _, a := range args {
|
|
136
|
+
parts = append(parts, powershellQuote(a))
|
|
137
|
+
}
|
|
138
|
+
return strings.Join(parts, " ")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// powershellQuote PowerShell 单引号字符串字面量。
|
|
142
|
+
//
|
|
143
|
+
// 单引号字符串内**只有两种转义**:” 表示一个字面单引号,
|
|
144
|
+
// 其余(含 $ ` " )全部按字面量处理 —— 这正是我们要的:提示词里的
|
|
145
|
+
// 美元符号、反引号、双引号都不用操心。
|
|
146
|
+
func powershellQuote(v string) string {
|
|
147
|
+
return "'" + strings.ReplaceAll(v, "'", "''") + "'"
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// emptyArgAsFlagValue 把「flag + 空串值」这一对参数改写成 `flag=` 形式。
|
|
151
|
+
//
|
|
152
|
+
// ## 为什么必须改写(2026-10-01 实测,本文件存在的第二个理由)
|
|
153
|
+
//
|
|
154
|
+
// 引擎 CLI 里有一批「用空串表达含义」的 flag,claude 的 `--tools ""` 就是
|
|
155
|
+
// 典型(空串 = 禁用全部内置工具,见 claude.go buildArgsBase 的 toolsIsOff 分支)。
|
|
156
|
+
//
|
|
157
|
+
// 传法有两种,**只有一种在 Windows 上活着**:
|
|
158
|
+
//
|
|
159
|
+
// --tools "" ❌ 0 行输出、~320ms 挂
|
|
160
|
+
// --tools= ✅ 正常输出
|
|
161
|
+
//
|
|
162
|
+
// ⚠️ 机制不是「cmd.exe 吞空串」—— 2026-10-01 的对照实测(裸 claude.exe
|
|
163
|
+
// 直连,绕开一切 shim)里 `--tools ""` 一样挂,所以**这跟 .cmd/cmd.exe
|
|
164
|
+
// 无关**。真实机制是:**Windows 命令行本身无法可靠传递空串参数**。
|
|
165
|
+
// Go 的 `syscall.EscapeArg` 把空串编成 `""`,可命令行解析层不保证把它
|
|
166
|
+
// 当成一个实参传下去(本机实测就是被丢弃),于是 CLI 收到的 argv 表
|
|
167
|
+
// 少了一个元素 → 后续位置参数**整体错位** → 表现为「认不出 prompt」:
|
|
168
|
+
//
|
|
169
|
+
// Input must be provided either through stdin or as a prompt argument
|
|
170
|
+
//
|
|
171
|
+
// 2×2 交叉实测(单行 / 多行 / 单换行 × split / merged)结论一致:
|
|
172
|
+
// 只要是独立空串 argv 就 ~320ms 挂,**与换行、与中文都无关**。
|
|
173
|
+
// `-p ""`(flag 自己吃空串)同样挂;而 `--model ""` + `--tools=` 正常 ——
|
|
174
|
+
// 说明触发条件是「flag 与空串相邻」这一形状,不是某个具体 flag。
|
|
175
|
+
//
|
|
176
|
+
// `--tools=` 只有一个 argv 元素、不含空串,绕开了这个坑,且对 CLI 等价
|
|
177
|
+
// (所有这些 CLI 都同时接受 `--flag value` 与 `--flag=value` 两种写法)。
|
|
178
|
+
//
|
|
179
|
+
// unix 上不做改写:argv 原样传,空串本来就是合法的空参数。
|
|
180
|
+
func emptyArgAsFlagValue(args []string, flag string) []string {
|
|
181
|
+
out := make([]string, 0, len(args))
|
|
182
|
+
for i := 0; i < len(args); i++ {
|
|
183
|
+
// 命中 `flag` + 空串值这一对 → 合并成 `flag=`
|
|
184
|
+
if args[i] == flag && i+1 < len(args) && args[i+1] == "" {
|
|
185
|
+
out = append(out, flag+"=")
|
|
186
|
+
i++ // 跳过被合并的空串
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
out = append(out, args[i])
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// launch_test.go - 平台包装层的回归测试(2026-10-01)。
|
|
4
|
+
//
|
|
5
|
+
// 这组断言守的是两个**静默**退化:参数被 cmd.exe 吞掉、思维链混进正文。
|
|
6
|
+
// 两者都不报错、退出码都是 0,只能靠断言挡住。
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// TestEmptyArgAsFlagValue 「flag + 空串」必须改写成 flag=。
|
|
14
|
+
//
|
|
15
|
+
// 这是 Windows 上必需的一步:--tools "" 的空串是独立 argv 元素,
|
|
16
|
+
// 经 .cmd 传参时被丢弃,CLI 收到残缺参数表后表现是「认不出 prompt」。
|
|
17
|
+
func TestEmptyArgAsFlagValue(t *testing.T) {
|
|
18
|
+
cases := []struct {
|
|
19
|
+
name string
|
|
20
|
+
in []string
|
|
21
|
+
want []string
|
|
22
|
+
}{
|
|
23
|
+
{"独立空串改写", []string{"-p", "--tools", "", "--model", "m"},
|
|
24
|
+
[]string{"-p", "--tools=", "--model", "m"}},
|
|
25
|
+
{"末尾空串", []string{"--tools", ""}, []string{"--tools="}},
|
|
26
|
+
{"无空串不动", []string{"--tools", "Read,Bash"}, []string{"--tools", "Read,Bash"}},
|
|
27
|
+
{"flag 不误伤其他值", []string{"--model", "", "--tools", "x"},
|
|
28
|
+
[]string{"--model", "", "--tools", "x"}},
|
|
29
|
+
{"空 flag 不匹配", []string{"--tool", ""}, []string{"--tool", ""}},
|
|
30
|
+
{"空数组", []string{}, []string{}},
|
|
31
|
+
}
|
|
32
|
+
for _, c := range cases {
|
|
33
|
+
t.Run(c.name, func(t *testing.T) {
|
|
34
|
+
got := emptyArgAsFlagValue(c.in, "--tools")
|
|
35
|
+
if len(got) != len(c.want) {
|
|
36
|
+
t.Fatalf("长度 = %d(%q),want %d(%q)", len(got), got, len(c.want), c.want)
|
|
37
|
+
}
|
|
38
|
+
for i := range got {
|
|
39
|
+
if got[i] != c.want[i] {
|
|
40
|
+
t.Fatalf("第 %d 个 = %q,want %q", i, got[i], c.want[i])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// TestPowershellQuoteLiteral 提示词里的元字符必须原样保留。
|
|
48
|
+
//
|
|
49
|
+
// PowerShell 单引号字符串里只有一种转义(”),所以 $ ` " 都自动安全。
|
|
50
|
+
// 这条断言是「不要偷懒改成双引号字符串」的护栏 —— 换成双引号后
|
|
51
|
+
// $var 会被 PowerShell 展开,提示词内容就变了。
|
|
52
|
+
func TestPowershellQuoteLiteral(t *testing.T) {
|
|
53
|
+
cases := []struct{ in, want string }{
|
|
54
|
+
{"abc", "'abc'"},
|
|
55
|
+
{"", "''"},
|
|
56
|
+
{"$env:PATH", "'$env:PATH'"},
|
|
57
|
+
{"say \"hi\"", `'say "hi"'`},
|
|
58
|
+
{"back`tick", "'back`tick'"},
|
|
59
|
+
{"it's", "'it''s'"},
|
|
60
|
+
{"a\nb", "'a\nb'"}, // 换行在单引号内是字面量,不需转义
|
|
61
|
+
}
|
|
62
|
+
for _, c := range cases {
|
|
63
|
+
if got := powershellQuote(c.in); got != c.want {
|
|
64
|
+
t.Errorf("powershellQuote(%q) = %s,want %s", c.in, got, c.want)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// TestPowerShellScriptShape 生成的调用脚本形状正确(& 调用运算符 + 逐参数单引号)。
|
|
70
|
+
func TestPowerShellScriptShape(t *testing.T) {
|
|
71
|
+
got := powershellInvokeScript(`C:\a b\claude.cmd`, []string{"-p", "it's", "x\ny"})
|
|
72
|
+
want := `& 'C:\a b\claude.cmd' '-p' 'it''s' 'x` + "\n" + `y'`
|
|
73
|
+
if got != want {
|
|
74
|
+
t.Fatalf("got = %q\nwant = %q", got, want)
|
|
75
|
+
}
|
|
76
|
+
// 换行绝不能把脚本截断(PowerShell -Command 按行解析)。
|
|
77
|
+
if strings.Count(got, "\n") != 1 || !strings.Contains(got, "'x\ny'") {
|
|
78
|
+
t.Fatalf("换行未被安全包住:%q", got)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// TestNeedsPowerShellWrap 后缀判定。
|
|
83
|
+
//
|
|
84
|
+
// 期望值按**运行平台**取值:unix 上 needsPowerShellWrap 一律 false
|
|
85
|
+
// (launchWrap 直接原样返回),Windows 上才看后缀。
|
|
86
|
+
// 写死 Windows 的期望会让本文件在 macOS/CI(linux) 上必然红。
|
|
87
|
+
func TestNeedsPowerShellWrap(t *testing.T) {
|
|
88
|
+
wrap := platformIsWindows() // 平台总开关
|
|
89
|
+
cases := []struct {
|
|
90
|
+
bin string
|
|
91
|
+
wantWrap bool // 仅当 wrap == true 时才参与断言
|
|
92
|
+
}{
|
|
93
|
+
{`C:\npm\claude.cmd`, true},
|
|
94
|
+
{`C:\npm\foo.BAT`, true},
|
|
95
|
+
{`C:\npm\script.ps1`, true},
|
|
96
|
+
{`C:\trae\trae-cli.exe`, false},
|
|
97
|
+
{`/opt/homebrew/bin/claude`, false},
|
|
98
|
+
{"claude", false},
|
|
99
|
+
{"", false},
|
|
100
|
+
{"weird.cmd.txt", false}, // 只看最后一个后缀
|
|
101
|
+
}
|
|
102
|
+
for _, c := range cases {
|
|
103
|
+
want := c.wantWrap && wrap
|
|
104
|
+
if got := needsPowerShellWrap(c.bin); got != want {
|
|
105
|
+
t.Errorf("needsPowerShellWrap(%q) = %v,want %v(platformIsWindows=%v)",
|
|
106
|
+
c.bin, got, want, wrap)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// TestLaunchWrapKeepsOriginalWhenNoWrap 明确「不该包装时绝不包装」。
|
|
112
|
+
//
|
|
113
|
+
// unix 上 launchWrap 必须原样返回:任何改写都会静默改变既有行为
|
|
114
|
+
// (尤其不能改写空串 flag —— 那是 Windows 专属改写)。
|
|
115
|
+
func TestLaunchWrapKeepsOriginalWhenNoWrap(t *testing.T) {
|
|
116
|
+
if platformIsWindows() {
|
|
117
|
+
t.Skip("Windows 上 .cmd 会被包装,本断言只对 unix 成立")
|
|
118
|
+
}
|
|
119
|
+
in := []string{"-p", "--tools", "", "prompt\nwith\nnewlines"}
|
|
120
|
+
spec := launchWrap("/opt/homebrew/bin/claude", in)
|
|
121
|
+
if spec.wrapped {
|
|
122
|
+
t.Fatal("unix 上不应包装")
|
|
123
|
+
}
|
|
124
|
+
if spec.bin != "/opt/homebrew/bin/claude" {
|
|
125
|
+
t.Fatalf("bin = %q,应原样保留", spec.bin)
|
|
126
|
+
}
|
|
127
|
+
if len(spec.args) != len(in) {
|
|
128
|
+
t.Fatalf("args = %q,应原样保留(含空串)", spec.args)
|
|
129
|
+
}
|
|
130
|
+
// 逐项比对:空串必须还在原位(launchWrap 不许碰 unix 的 argv)。
|
|
131
|
+
for i := range in {
|
|
132
|
+
if spec.args[i] != in[i] {
|
|
133
|
+
t.Fatalf("第 %d 个 = %q,want %q", i, spec.args[i], in[i])
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// TestLaunchWrapNativeExeUntouched Windows 上:原生 .exe 不包装、但空串仍改写。
|
|
139
|
+
//
|
|
140
|
+
// 这条挡的是「把所有 Windows bin 都包一层 PowerShell」的偷懒改法 ——
|
|
141
|
+
// 那样会多出一个常驻 powershell 进程,进程组杀、超时、信号转发全部多一跳,
|
|
142
|
+
// 回归面极大。原生 exe 必须直连。
|
|
143
|
+
func TestLaunchWrapNativeExeUntouched(t *testing.T) {
|
|
144
|
+
if !platformIsWindows() {
|
|
145
|
+
t.Skip("本断言只对 Windows 成立")
|
|
146
|
+
}
|
|
147
|
+
spec := launchWrap(`C:\trae\trae-cli.exe`, []string{"-p", "--tools", "", "x"})
|
|
148
|
+
if spec.wrapped {
|
|
149
|
+
t.Fatal(".exe 不该被包装成 PowerShell")
|
|
150
|
+
}
|
|
151
|
+
if spec.bin != `C:\trae\trae-cli.exe` {
|
|
152
|
+
t.Fatalf("bin = %q", spec.bin)
|
|
153
|
+
}
|
|
154
|
+
// 空串改写仍生效(它不依赖包装,是 argv 层面的修补)。
|
|
155
|
+
joined := strings.Join(spec.args, "\x00")
|
|
156
|
+
if strings.Contains(joined, "--tools\x00\x00") {
|
|
157
|
+
t.Fatalf("空串未被改写:%q", spec.args)
|
|
158
|
+
}
|
|
159
|
+
if !strings.Contains(joined, "--tools=") {
|
|
160
|
+
t.Fatalf("缺少 --tools=:%q", spec.args)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// TestLaunchWrapWindowsScriptShape Windows 上:包装后的参数形状必须可 exec。
|
|
165
|
+
//
|
|
166
|
+
// 精确断言整个 argv(不是 contains 弱匹配)—— 参数顺序/个数错了
|
|
167
|
+
// PowerShell 会静默执行错的命令,而这正是本层要防的那类失败。
|
|
168
|
+
func TestLaunchWrapWindowsScriptShape(t *testing.T) {
|
|
169
|
+
if !platformIsWindows() {
|
|
170
|
+
t.Skip("本断言只对 Windows 成立")
|
|
171
|
+
}
|
|
172
|
+
spec := launchWrap(`C:\npm\claude.cmd`, []string{"-p", "--tools", "", "multi\nline"})
|
|
173
|
+
if !spec.wrapped {
|
|
174
|
+
t.Fatal(".cmd 应被包装成 PowerShell")
|
|
175
|
+
}
|
|
176
|
+
if spec.bin != powershellBin() {
|
|
177
|
+
t.Fatalf("bin = %q,want %q", spec.bin, powershellBin())
|
|
178
|
+
}
|
|
179
|
+
wantArgs := []string{
|
|
180
|
+
"-NoProfile",
|
|
181
|
+
"-ExecutionPolicy",
|
|
182
|
+
"Bypass",
|
|
183
|
+
"-Command",
|
|
184
|
+
`& 'C:\npm\claude.cmd' '-p' '--tools=' 'multi` + "\n" + `line'`,
|
|
185
|
+
}
|
|
186
|
+
if len(spec.args) != len(wantArgs) {
|
|
187
|
+
t.Fatalf("args = %q\nwant = %q", spec.args, wantArgs)
|
|
188
|
+
}
|
|
189
|
+
for i := range wantArgs {
|
|
190
|
+
if spec.args[i] != wantArgs[i] {
|
|
191
|
+
t.Fatalf("第 %d 个 = %q,want %q", i, spec.args[i], wantArgs[i])
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 独立空参数绝不能出现在脚本里(cmd.exe 会把它吞掉)。
|
|
195
|
+
script := spec.args[len(spec.args)-1]
|
|
196
|
+
if strings.Contains(script, "''") {
|
|
197
|
+
t.Fatalf("脚本里出现独立空参数:%q", script)
|
|
198
|
+
}
|
|
199
|
+
// 多行提示词必须仍是**一个**引号包裹的参数,不能把 -Command 脚本截断。
|
|
200
|
+
if !strings.Contains(script, "'multi\nline'") {
|
|
201
|
+
t.Fatalf("多行提示词未完整保留:%q", script)
|
|
202
|
+
}
|
|
203
|
+
if strings.Count(script, "\n") != 1 {
|
|
204
|
+
t.Fatalf("脚本被换行截断:%q", script)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//go:build !windows
|
|
2
|
+
|
|
3
|
+
package agent
|
|
4
|
+
|
|
5
|
+
// launch_unix.go - Unix (darwin/linux) 侧的 launcher 解析。
|
|
6
|
+
//
|
|
7
|
+
// unix 上没有这一层:npm 装的 shim 是无扩展名的脚本(带 shebang),
|
|
8
|
+
// exec 可以直接执行;launchWrap 在 launch.go 里对非 Windows 直接原样返回。
|
|
9
|
+
// 本文件只提供两个平台分派用的最小实现。
|
|
10
|
+
|
|
11
|
+
func platformIsWindows() bool { return false }
|
|
12
|
+
|
|
13
|
+
// powershellBin 在 unix 上不会被调用(launchWrap 不走包装分支)。
|
|
14
|
+
// 保留实现只为让 launch.go 保持可编译,不返回真路径。
|
|
15
|
+
func powershellBin() string { return "" }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package agent
|
|
4
|
+
|
|
5
|
+
// launch_windows.go - Windows 侧的 launcher 解析。
|
|
6
|
+
//
|
|
7
|
+
// PowerShell 可执行名按 powershell.exe → powershell → 固定名 顺序找。
|
|
8
|
+
// 优先找实际存在的那个,避免在只有 pwsh 的精简环境里退化成无效命令。
|
|
9
|
+
|
|
10
|
+
import "os/exec"
|
|
11
|
+
|
|
12
|
+
func platformIsWindows() bool { return true }
|
|
13
|
+
|
|
14
|
+
// powershellBin 找可用的 PowerShell。找不到时返回 "powershell.exe"
|
|
15
|
+
// 作为最后的兜底(让 exec 报「找不到文件」这种能看懂的错,
|
|
16
|
+
// 而不是静默失败)。
|
|
17
|
+
func powershellBin() string {
|
|
18
|
+
for _, name := range []string{"powershell.exe", "powershell", "pwsh.exe", "pwsh"} {
|
|
19
|
+
if p, err := exec.LookPath(name); err == nil && p != "" {
|
|
20
|
+
return p
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return "powershell.exe"
|
|
24
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// limitnotice.go - 「限流 / 额度」通告被当成助手正文发回来时的判据(2026-10-10)。
|
|
4
|
+
//
|
|
5
|
+
// 由来(用户报障原话):「codebuddy ai 没有额度了 没有输出一个独立的错误消息用于提示吗
|
|
6
|
+
// 现在混在回复里了」—— 引擎(供应商)在额度用尽时把通告**当正文**发了回来,而且往往
|
|
7
|
+
// **粘在这一轮正文的末尾**(实测原文两版,中英各一):
|
|
8
|
+
//
|
|
9
|
+
// 英文(实测这版前面没有换行,直接粘在上一句句号后面,请求 id 尾巴跟在最后):
|
|
10
|
+
// …file's end.429 usage exceeds frequency limit, but don't worry, your usage will reset
|
|
11
|
+
// at 2026-10-11 10:32:59 UTC+8, alternatively, you can switch to the other models to
|
|
12
|
+
// continue using it. (0l1a124d…/0l1a123c3-…)
|
|
13
|
+
// 中文(2026-09-19 实测):
|
|
14
|
+
// 429 您的使用量已超出频率限制,将在 2026-09-19 17:49:56 UTC+8 重置,您也可以切换其他模型继续使用。
|
|
15
|
+
//
|
|
16
|
+
// 这不是回答,是**失败**:原样转发出去,消费方看到的是「一段正经正文 + 一句额度通告 +
|
|
17
|
+
// 已完成」—— 用户既看不出这一轮其实没干完,也不知道该换模型(最坏的那类假成功)。
|
|
18
|
+
//
|
|
19
|
+
// 与本包既有判据的分工(三条都不同,别互相替代):
|
|
20
|
+
// - runner.go 的 isRetryable 认的是「**错误**消息里有没有 429 / rate limit」;
|
|
21
|
+
// - 会话文件那条(cli 侧捞 providerData.error.message)认的是「正文**为空**、原因只在
|
|
22
|
+
// 引擎自己的会话文件里」;
|
|
23
|
+
// - 本条认的是「**正文末尾夹着**通告」(状态码在中间)—— 上面两条都认不出来。
|
|
24
|
+
//
|
|
25
|
+
// 判据(五条同时成立,宁可漏判也不误伤正常回答):
|
|
26
|
+
// ① 状态码是已知的那几个(429/402/401/403/5xx)—— 不认「任意三位数」:否则正文里
|
|
27
|
+
// 一句「端口 8080 与 usage 有关」都会被当成通告;
|
|
28
|
+
// ② 它落在句子边界上(正文开头 / 换行 / 句末标点或冒号之后,**普通空格不算**)且后面
|
|
29
|
+
// 紧跟空格 —— 实测那个英文版前面是「.」,所以边界里必须有句末标点(不能只认行首);
|
|
30
|
+
// ③ 从它到正文结尾这一段里有「限流 / 额度」措辞;
|
|
31
|
+
// ④ 这一段里还有「重置 / 切换」类提示;
|
|
32
|
+
// ⑤ 这一段里有完整的日期时间戳(`2026-10-11` 这种)—— 这条是判据的**分水岭**:用户真在
|
|
33
|
+
// 问「429 是什么意思」时,模型答「429 表示限流,等额度重置即可」(措辞全中,但没有
|
|
34
|
+
// 「什么时候恢复」的时刻)不会被误判;而真通告一定带着恢复时刻。
|
|
35
|
+
//
|
|
36
|
+
// 另加两条形状约束:整段是**一句话**(段内无句末标点,末尾那个可有可无)且不超过 400 字
|
|
37
|
+
// —— 通告就是一句话,几百字的长回答不该被整段吞掉。
|
|
38
|
+
//
|
|
39
|
+
// ⚠️ 起点从后往前找(见实现):拿到的是最短的那一段 —— 前面正文里万一也出现过状态码,
|
|
40
|
+
// 取到的是紧贴结尾的真通告,而不是「从那个状态码开始的一大段」。
|
|
41
|
+
//
|
|
42
|
+
// ⚠️ 纯函数(不碰进程 / 磁盘 / 全局状态):单测直接钉(见 limitnotice_test.go)。
|
|
43
|
+
|
|
44
|
+
import (
|
|
45
|
+
"regexp"
|
|
46
|
+
"strings"
|
|
47
|
+
"unicode/utf8"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// limitNoticeMaxRunes 通告那一段的长度上限(一句话;长回答不该被整段吞掉)。
|
|
51
|
+
const limitNoticeMaxRunes = 400
|
|
52
|
+
|
|
53
|
+
var (
|
|
54
|
+
// 起点:句子边界(正文开头 / 换行 / 句末标点 / 冒号)+ 已知状态码 + 一个空格。
|
|
55
|
+
// ⚠️ 刻意**不认「普通空格之后」**:模型正经回答里写「HTTP 429 是限流,额度会在 … 重置」
|
|
56
|
+
// 这种科普句时,状态码前面就是一个空格 —— 认了它就会把正常回答当通告(实测过的误伤)。
|
|
57
|
+
limitNoticeStartRe = regexp.MustCompile(`(?:^|[\n。!?!?.;;::])((?:429|402|401|403|5\d{2})[ \t])`)
|
|
58
|
+
// 限流 / 额度措辞(中英两套)。
|
|
59
|
+
limitNoticeNounRe = regexp.MustCompile(`(?i)超出频率限制|频率限制|限流|rate[\s_-]?limit|frequency limit|quota|额度|余额|欠费|usage exceeds|usage will reset`)
|
|
60
|
+
// 重置 / 切换类提示(通告一定会告诉用户「等多久」或「换个模型」)。
|
|
61
|
+
limitNoticeHintRe = regexp.MustCompile(`(?i)重置|切换|重试|稍后|再试|reset|retry|switch`)
|
|
62
|
+
// 恢复时刻的时间戳(判据的分水岭,见上)。
|
|
63
|
+
limitNoticeStampRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
|
|
64
|
+
// 排障用的请求 id 尾巴(形如 `(0l1a124d…/0l1a123c3-…)`)—— 它属于通告,
|
|
65
|
+
// 判「整段一句话」时要先剥掉。
|
|
66
|
+
limitNoticeIDTailRe = regexp.MustCompile(`\s*\([^()\n]{4,200}\)\s*$`)
|
|
67
|
+
// 末尾那个句号(可有可无,剥掉后再判「段内还有没有句末标点」)。
|
|
68
|
+
limitNoticeEndRe = regexp.MustCompile(`[。.!!??]\s*$`)
|
|
69
|
+
// 段内的句末标点(出现即说明这段不止一句话 → 不是通告)。
|
|
70
|
+
limitNoticeSeptRe = regexp.MustCompile(`[。!?!?]`)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// splitLimitNotice 判 text 的**末尾**是不是夹着一句供应商的限流/额度通告。
|
|
74
|
+
//
|
|
75
|
+
// 命中返回 (通告原文, 通告之前真正的正文, true);不命中返回 ("", "", false)。
|
|
76
|
+
// 通告原文已剥掉请求 id 尾巴与末尾标点、内部空白压成单空格 —— 它要直接进界面的错因行;
|
|
77
|
+
// 正文可能为空串(这一轮引擎只回了通告)。
|
|
78
|
+
func splitLimitNotice(text string) (notice, body string, ok bool) {
|
|
79
|
+
s := strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
|
80
|
+
if strings.TrimSpace(s) == "" {
|
|
81
|
+
return "", "", false
|
|
82
|
+
}
|
|
83
|
+
locs := limitNoticeStartRe.FindAllStringSubmatchIndex(s, -1)
|
|
84
|
+
// 从后往前:先试最短的那一段(它才是紧贴结尾的通告)。
|
|
85
|
+
for i := len(locs) - 1; i >= 0; i-- {
|
|
86
|
+
at := locs[i][2] // 子匹配 1(状态码本身)的起点:跳过前面那个边界字符
|
|
87
|
+
seg := strings.TrimSpace(s[at:])
|
|
88
|
+
if seg == "" || utf8.RuneCountInString(seg) > limitNoticeMaxRunes {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
core := strings.TrimSpace(limitNoticeEndRe.ReplaceAllString(limitNoticeIDTailRe.ReplaceAllString(seg, ""), ""))
|
|
92
|
+
if limitNoticeSeptRe.MatchString(core) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if !limitNoticeNounRe.MatchString(core) ||
|
|
96
|
+
!limitNoticeHintRe.MatchString(core) ||
|
|
97
|
+
!limitNoticeStampRe.MatchString(core) {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
return strings.Join(strings.Fields(core), " "), strings.TrimRight(s[:at], " \t\r\n\u3000"), true
|
|
101
|
+
}
|
|
102
|
+
return "", "", false
|
|
103
|
+
}
|