magic-agent 1.0.0

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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,726 @@
1
+ package agent
2
+
3
+ // dsh_sdk_test.go - dsh **SDK 通道**(`dsh --profile sdk`)单元测试。
4
+ //
5
+ // 假 CLI 是一段 POSIX sh:按 `--profile` 分流 —— `sdk` 走 JSON-RPC 循环(读一行请求、
6
+ // 按方法名回一行响应/通知),其它 profile 走 headless 行为。这样同一条二进制既能验
7
+ // 「SDK 通道可用」,也能验「SDK 不可用 → 回退 headless」。
8
+ //
9
+ // 依据(协议形状来自官方 d.ts,不是从样本猜的):
10
+ //
11
+ // initialize → {"serverInfo":{"name":"deepseek-harness-sdk-runtime","version":"0.0.1"}}
12
+ // session/prompt → {"messageId":…}
13
+ // ← session.event {sessionId, event:{type, data}}
14
+ // assistant/message → message.content[] 的 reasoning / text 块
15
+ // tool/call → {turn, step, callId, name, arguments}
16
+ // tool/result → {message:{source:{kind:"tool",callId}, content:[ToolResultBlock]}}
17
+ // turn/end → {turn, reason:{kind:"completed"}}
18
+ // ← session.status {sessionId, status:"running"|"idle"}
19
+
20
+ import (
21
+ "context"
22
+ "errors"
23
+ "os"
24
+ "path/filepath"
25
+ "regexp"
26
+ "strings"
27
+ "sync"
28
+ "testing"
29
+ "time"
30
+ )
31
+
32
+ // dshSDKStubOpts 假 CLI 的可注入行为(零值 = 一条成功的一轮)。
33
+ type dshSDKStubOpts struct {
34
+ // InitResponse initialize 的响应行(空 = 默认成功握手)。
35
+ InitResponse string
36
+ // PromptEvents session/prompt 之后推送的事件行(sh 片段,可用 $sid)。
37
+ PromptEvents string
38
+ // HeadlessBody 非 sdk profile 时的行为(空 = 打印一行 headless 正文)。
39
+ HeadlessBody string
40
+ // SilentSDK true = sdk profile 起来就退出(模拟旧版 dsh 没有该 profile)。
41
+ SilentSDK bool
42
+ }
43
+
44
+ // dshSDKStub 造一个会说 SDK 协议的假 dsh,返回 (bin, argsLog, stdinLog)。
45
+ // argsLog 追加每次启动的参数(不截断,便于判断起了几次、走的哪条通道);
46
+ // stdinLog 追加每条收到的 JSON-RPC 请求(便于断言 initialize 的载荷)。
47
+ func dshSDKStub(t *testing.T, dir string, opts dshSDKStubOpts) (bin, argsLog, stdinLog string) {
48
+ t.Helper()
49
+ argsLog = filepath.Join(dir, "args.log")
50
+ stdinLog = filepath.Join(dir, "stdin.log")
51
+ bin = filepath.Join(dir, "dsh")
52
+
53
+ if opts.InitResponse == "" {
54
+ opts.InitResponse = `{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime","version":"0.0.1"}}}`
55
+ }
56
+ if opts.PromptEvents == "" {
57
+ opts.PromptEvents = defaultDshSDKEvents
58
+ }
59
+ if opts.HeadlessBody == "" {
60
+ opts.HeadlessBody = "echo 'headless 正文'"
61
+ }
62
+ silent := ""
63
+ if opts.SilentSDK {
64
+ silent = "exit 0"
65
+ }
66
+
67
+ script := "#!/bin/sh\n" +
68
+ "printf '%s\\n' \"$*\" >> " + argsLog + "\n" +
69
+ "if [ \"$1\" = \"--profile\" ] && [ \"$2\" = \"sdk\" ]; then\n" +
70
+ silent + "\n" +
71
+ " turn=0\n" +
72
+ " while IFS= read -r line; do\n" +
73
+ " printf '%s\\n' \"$line\" >> " + stdinLog + "\n" +
74
+ " case \"$line\" in\n" +
75
+ " *initialize*)\n" +
76
+ " printf '%s\\n' '" + opts.InitResponse + "'\n" +
77
+ " ;;\n" +
78
+ " *session/prompt*)\n" +
79
+ " sid=$(printf '%s' \"$line\" | sed -n 's/.*\"sessionId\":\"\\([^\"]*\\)\".*/\\1/p')\n" +
80
+ " turn=$((turn+1))\n" +
81
+ " printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"messageId\":\"m1\"}}'\n" +
82
+ opts.PromptEvents + "\n" +
83
+ " ;;\n" +
84
+ " *shutdown*)\n" +
85
+ " printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}'\n" +
86
+ " ;;\n" +
87
+ " esac\n" +
88
+ " done\n" +
89
+ " exit 0\n" +
90
+ "fi\n" +
91
+ opts.HeadlessBody + "\n"
92
+
93
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ return bin, argsLog, stdinLog
97
+ }
98
+
99
+ // defaultDshSDKEvents 一轮标准事件:推理 → 工具调用 → 工具结果 → 正文 → turn/end。
100
+ //
101
+ // 注意 printf 的 %s 不解释参数里的反斜杠(只有格式串才解释),所以 JSON 里要表达的
102
+ // `\n` 原样写 `\n`、要表达的 `\"` 在 sh 双引号里写 `\\\"`。
103
+ const defaultDshSDKEvents = ` printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"assistant/message\",\"data\":{\"turn\":$turn,\"step\":1,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"reasoning\",\"text\":\"先看看仓库\\n\"}]}}}}}"
104
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"tool/call\",\"data\":{\"turn\":$turn,\"step\":1,\"callId\":\"c1\",\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}}}"
105
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"tool/result\",\"data\":{\"turn\":$turn,\"step\":1,\"message\":{\"source\":{\"kind\":\"tool\",\"callId\":\"c1\"},\"content\":[{\"type\":\"tool-result\",\"toolCallId\":\"c1\",\"content\":[{\"type\":\"text\",\"text\":\"a.txt\\n\"}],\"isError\":false}]}}}}}"
106
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"assistant/message\",\"data\":{\"turn\":$turn,\"step\":2,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"目录里有 a.txt\"}]}}}}}"
107
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"turn/end\",\"data\":{\"turn\":$turn,\"reason\":{\"kind\":\"completed\"}}}}}"
108
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.status\",\"params\":{\"sessionId\":\"$sid\",\"status\":\"idle\"}}"`
109
+
110
+ // dshSDKHome 把 $DSH_HOME 指向临时目录并写入 agent-default-model(initialize 的必填项)。
111
+ func dshSDKHome(t *testing.T, provider, model string) {
112
+ t.Helper()
113
+ home := t.TempDir()
114
+ t.Setenv("DSH_HOME", home)
115
+ cfg := "agent-default-model:\n provider: " + provider + "\n model: " + model + "\n"
116
+ if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(cfg), 0o600); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ }
120
+
121
+ // 默认(Profile 空)走 SDK 通道:启动参数是 `--profile sdk`,正文来自 session.event。
122
+ func TestDshSDKIsDefaultTransport(t *testing.T) {
123
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
124
+ dir := t.TempDir()
125
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{})
126
+
127
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
128
+ Messages: []Message{{Role: "user", Content: "看看目录"}},
129
+ })
130
+ if err != nil {
131
+ t.Fatalf("Complete: %v", err)
132
+ }
133
+ if resp.Text != "目录里有 a.txt" {
134
+ t.Errorf("Text = %q want 目录里有 a.txt", resp.Text)
135
+ }
136
+ // 模型标签用 initialize 实际用的路由,不是配置层猜的。
137
+ if resp.Model != "modelverse/deepseek-v4.1-flash" {
138
+ t.Errorf("Model = %q want modelverse/deepseek-v4.1-flash", resp.Model)
139
+ }
140
+ // dsh 续不上会话 → 不回可续接的 id(见 errDshNoSessionResume)。
141
+ if resp.SessionID != "" {
142
+ t.Errorf("SessionID = %q want 空(dsh 不支持续接)", resp.SessionID)
143
+ }
144
+ args := readFileString(t, argsLog)
145
+ if !strings.Contains(args, "--profile sdk") {
146
+ t.Errorf("默认应走 SDK 通道: %q", args)
147
+ }
148
+ if strings.Contains(args, "--profile headless") {
149
+ t.Errorf("SDK 可用时不该回退 headless: %q", args)
150
+ }
151
+ }
152
+
153
+ // 流式:推理 / 工具调用 / 工具结果 / 正文 / turn 收尾,全部按顺序上报。
154
+ func TestDshSDKStreamEmitsAllEventKinds(t *testing.T) {
155
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
156
+ dir := t.TempDir()
157
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{})
158
+
159
+ var mu sync.Mutex
160
+ var events []StreamEvent
161
+ res, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
162
+ Messages: []Message{{Role: "user", Content: "看看目录"}},
163
+ }, func(ev StreamEvent) {
164
+ mu.Lock()
165
+ events = append(events, ev)
166
+ mu.Unlock()
167
+ })
168
+ if err != nil {
169
+ t.Fatalf("Stream: %v", err)
170
+ }
171
+ if res.Text != "目录里有 a.txt" {
172
+ t.Errorf("Text = %q", res.Text)
173
+ }
174
+ if res.Thinking != "先看看仓库\n" {
175
+ t.Errorf("Thinking = %q want %q", res.Thinking, "先看看仓库\n")
176
+ }
177
+ // 工具调用:name + arguments(原样 JSON 字符串)+ callId,结果按 callId 回填。
178
+ if len(res.Tools) != 1 {
179
+ t.Fatalf("Tools = %v want 1 条", res.Tools)
180
+ }
181
+ tc := res.Tools[0]
182
+ if tc.Name != "bash" || tc.ID != "c1" {
183
+ t.Errorf("ToolCall = %+v want name=bash id=c1", tc)
184
+ }
185
+ if tc.Args != `{"command":"ls"}` {
186
+ t.Errorf("ToolCall.Args = %q want {\"command\":\"ls\"}", tc.Args)
187
+ }
188
+ if tc.Result != "a.txt\n" {
189
+ t.Errorf("ToolCall.Result = %q want a.txt\\n", tc.Result)
190
+ }
191
+
192
+ mu.Lock()
193
+ defer mu.Unlock()
194
+ var kinds []StreamEventKind
195
+ for _, ev := range events {
196
+ kinds = append(kinds, ev.Kind)
197
+ }
198
+ want := []StreamEventKind{KindThinking, KindToolUse, KindToolResult, KindText, KindTurnEnd}
199
+ if strings.Join(kindsToStrings(kinds), ",") != strings.Join(kindsToStrings(want), ",") {
200
+ t.Errorf("事件序列 = %v want %v", kinds, want)
201
+ }
202
+ // KindToolUse 必须带 name/id/args(消费方靠它渲染工具卡片)。
203
+ for _, ev := range events {
204
+ if ev.Kind == KindToolUse {
205
+ if ev.Name != "bash" || ev.ID != "c1" || ev.Text != `{"command":"ls"}` {
206
+ t.Errorf("KindToolUse 载荷不全: %+v", ev)
207
+ }
208
+ }
209
+ if ev.Kind == KindToolResult && ev.ID != "c1" {
210
+ t.Errorf("KindToolResult 应带关联 callId: %+v", ev)
211
+ }
212
+ if ev.Kind == KindTurnEnd && ev.SessionID != res.SessionID {
213
+ t.Errorf("KindTurnEnd 应带会话锚点: got %q want %q", ev.SessionID, res.SessionID)
214
+ }
215
+ }
216
+ }
217
+
218
+ // 别的会话的事件不混进本轮(协议原文:session.event 覆盖运行时内**每个**会话)。
219
+ func TestDshSDKIgnoresOtherSessions(t *testing.T) {
220
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
221
+ dir := t.TempDir()
222
+ events := ` printf '%s\n' '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"别人的会话","event":{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"外来正文"}]}}}}}'
223
+ printf '%s\n' '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"别人的会话","event":{"type":"tool/call","data":{"turn":1,"step":1,"callId":"x9","name":"bash","arguments":"{}"}}}}'
224
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"assistant/message\",\"data\":{\"turn\":$turn,\"step\":1,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"我的正文\"}]}}}}}"
225
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"turn/end\",\"data\":{\"turn\":$turn,\"reason\":{\"kind\":\"completed\"}}}}}"`
226
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{PromptEvents: events})
227
+
228
+ res, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
229
+ Messages: []Message{{Role: "user", Content: "hi"}},
230
+ }, nil)
231
+ if err != nil {
232
+ t.Fatalf("Stream: %v", err)
233
+ }
234
+ if res.Text != "我的正文" {
235
+ t.Errorf("Text = %q want 我的正文(外来会话的正文不该混入)", res.Text)
236
+ }
237
+ if len(res.Tools) != 0 {
238
+ t.Errorf("Tools = %v want 空(外来会话的工具调用不该混入)", res.Tools)
239
+ }
240
+ }
241
+
242
+ // initialize 被服务端明确拒绝(模型没配)→ **真失败**,不静默回退 headless。
243
+ func TestDshSDKInitializeRejectedIsFatal(t *testing.T) {
244
+ dshSDKHome(t, "modelverse", "no-such-model")
245
+ dir := t.TempDir()
246
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{
247
+ InitResponse: `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"pi-ai provider \"modelverse\" has no configured model \"no-such-model\""}}`,
248
+ })
249
+
250
+ _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
251
+ Messages: []Message{{Role: "user", Content: "hi"}},
252
+ })
253
+ if err == nil {
254
+ t.Fatal("initialize 被拒应报错")
255
+ }
256
+ for _, want := range []string{"initialize 被拒绝", "no configured model", "modelverse"} {
257
+ if !strings.Contains(err.Error(), want) {
258
+ t.Errorf("错误应包含 %q: %v", want, err)
259
+ }
260
+ }
261
+ // 关键:不能换个通道把错误吞掉(换了就是静默改模型)。
262
+ if args := readFileString(t, argsLog); strings.Contains(args, "headless") {
263
+ t.Errorf("服务端拒绝时不该回退 headless: %q", args)
264
+ }
265
+ }
266
+
267
+ // SDK profile 起不来(旧版 dsh 没这个 profile)→ 回退 headless,并把代价写进 stderr。
268
+ func TestDshSDKUnavailableFallsBackToHeadless(t *testing.T) {
269
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
270
+ dir := t.TempDir()
271
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{
272
+ SilentSDK: true,
273
+ HeadlessBody: "echo 'headless 正文'",
274
+ })
275
+
276
+ old := stderr
277
+ var buf strings.Builder
278
+ stderr = &buf
279
+ defer func() { stderr = old }()
280
+
281
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
282
+ Messages: []Message{{Role: "user", Content: "hi"}},
283
+ })
284
+ if err != nil {
285
+ t.Fatalf("Complete: %v", err)
286
+ }
287
+ if resp.Text != "headless 正文" {
288
+ t.Errorf("Text = %q want headless 正文(应回退 headless)", resp.Text)
289
+ }
290
+ args := readFileString(t, argsLog)
291
+ if !strings.Contains(args, "--profile sdk") || !strings.Contains(args, "--profile headless") {
292
+ t.Errorf("应先试 sdk 再回退 headless: %q", args)
293
+ }
294
+ if warn := buf.String(); !strings.Contains(warn, "SDK 通道不可用") {
295
+ t.Errorf("回退应有明确告警: %q", warn)
296
+ }
297
+ }
298
+
299
+ // MaxTokens 在 SDK 通道上真的生效(initialize 的 maxTokens),headless 上没有这个能力。
300
+ func TestDshSDKMaxTokensForwarded(t *testing.T) {
301
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
302
+ dir := t.TempDir()
303
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
304
+
305
+ if _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
306
+ Messages: []Message{{Role: "user", Content: "hi"}},
307
+ MaxTokens: 4096,
308
+ }); err != nil {
309
+ t.Fatalf("Complete: %v", err)
310
+ }
311
+ init := readFileString(t, stdinLog)
312
+ if !strings.Contains(init, `"maxTokens":4096`) {
313
+ t.Errorf("initialize 应带 maxTokens:4096: %q", init)
314
+ }
315
+ if !strings.Contains(init, `"provider":"modelverse"`) || !strings.Contains(init, `"model":"deepseek-v4.1-flash"`) {
316
+ t.Errorf("initialize 应带配置层的 provider/model: %q", init)
317
+ }
318
+ }
319
+
320
+ // -m 在 SDK 通道上**生效**(initialize 的 provider/model)→ 不该再打「不生效」告警。
321
+ func TestDshSDKModelTakesEffectWithoutWarning(t *testing.T) {
322
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
323
+ dir := t.TempDir()
324
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
325
+
326
+ old := stderr
327
+ var buf strings.Builder
328
+ stderr = &buf
329
+ defer func() { stderr = old }()
330
+
331
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
332
+ Model: "modelverse/deepseek-v4-pro-0813",
333
+ Messages: []Message{{Role: "user", Content: "hi"}},
334
+ })
335
+ if err != nil {
336
+ t.Fatalf("Complete: %v", err)
337
+ }
338
+ if resp.Model != "modelverse/deepseek-v4-pro-0813" {
339
+ t.Errorf("Model = %q want 本轮实际用的 modelverse/deepseek-v4-pro-0813", resp.Model)
340
+ }
341
+ if !strings.Contains(readFileString(t, stdinLog), `"model":"deepseek-v4-pro-0813"`) {
342
+ t.Errorf("initialize 应带上 -m 指定的模型")
343
+ }
344
+ if buf.Len() != 0 {
345
+ t.Errorf("SDK 通道下 -m 生效,不该有告警: %q", buf.String())
346
+ }
347
+ }
348
+
349
+ // 一轮只有推理、没有正文 → 明确报错(不返回空 Text 让上层猜)。
350
+ func TestDshSDKEmptyTextIsError(t *testing.T) {
351
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
352
+ dir := t.TempDir()
353
+ events := ` printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"assistant/message\",\"data\":{\"turn\":$turn,\"step\":1,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"reasoning\",\"text\":\"只想不写\"}]}}}}}"
354
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"turn/end\",\"data\":{\"turn\":$turn,\"reason\":{\"kind\":\"completed\"}}}}}"`
355
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{PromptEvents: events})
356
+
357
+ _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
358
+ Messages: []Message{{Role: "user", Content: "hi"}},
359
+ })
360
+ if err == nil || !strings.Contains(err.Error(), "empty result") {
361
+ t.Fatalf("无正文应报错, got %v", err)
362
+ }
363
+ }
364
+
365
+ // 显式 Profile(自定义 profile / headless)→ 尊重调用方选择,不走 SDK。
366
+ func TestDshExplicitProfileSkipsSDK(t *testing.T) {
367
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
368
+ dir := t.TempDir()
369
+ bin, argsLog, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
370
+
371
+ for _, profile := range []string{dshDefaultProfile, "headless-cn"} {
372
+ if _, err := (&DshEngine{BinPath: bin, Profile: profile}).Complete(context.Background(), Request{
373
+ Messages: []Message{{Role: "user", Content: "hi"}},
374
+ }); err != nil {
375
+ t.Fatalf("profile=%s Complete: %v", profile, err)
376
+ }
377
+ }
378
+ args := readFileString(t, argsLog)
379
+ if strings.Contains(args, "--profile sdk") {
380
+ t.Errorf("显式 profile 时不该起 SDK 通道: %q", args)
381
+ }
382
+ if !strings.Contains(args, "--profile headless") || !strings.Contains(args, "--profile headless-cn") {
383
+ t.Errorf("应按显式 profile 启动: %q", args)
384
+ }
385
+ if _, err := os.Stat(stdinLog); err == nil {
386
+ t.Error("没走 SDK 通道时不该有 JSON-RPC 请求")
387
+ }
388
+ }
389
+
390
+ // sdkEnabled:默认与 "sdk" 走 SDK;其余(含 headless)走单 profile 路径。
391
+ func TestDshSDKEnabled(t *testing.T) {
392
+ t.Setenv(DshProfileEnv, "")
393
+ cases := []struct {
394
+ profile string
395
+ want bool
396
+ }{
397
+ {"", true},
398
+ {"sdk", true},
399
+ {" sdk ", true},
400
+ {"headless", false},
401
+ {"headless-cn", false},
402
+ }
403
+ for _, c := range cases {
404
+ if got := (&DshEngine{Profile: c.profile}).sdkEnabled(); got != c.want {
405
+ t.Errorf("sdkEnabled(profile=%q) = %v want %v", c.profile, got, c.want)
406
+ }
407
+ }
408
+ }
409
+
410
+ // MAGIC_AGENT_DSH_PROFILE:不改代码就能切回 headless(SDK 协议是预发布,留的退路)。
411
+ func TestDshProfileEnvOverride(t *testing.T) {
412
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
413
+ dir := t.TempDir()
414
+ bin, argsLog, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
415
+
416
+ // 环境变量 = headless → 不走 SDK。
417
+ t.Setenv(DshProfileEnv, "headless")
418
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
419
+ Messages: []Message{{Role: "user", Content: "hi"}},
420
+ })
421
+ if err != nil {
422
+ t.Fatalf("Complete: %v", err)
423
+ }
424
+ if resp.Text != "headless 正文" {
425
+ t.Errorf("Text = %q want headless 正文", resp.Text)
426
+ }
427
+ if args := readFileString(t, argsLog); strings.Contains(args, "--profile sdk") {
428
+ t.Errorf("env=headless 时不该起 SDK 通道: %q", args)
429
+ }
430
+ if _, err := os.Stat(stdinLog); err == nil {
431
+ t.Error("没走 SDK 通道时不该有 JSON-RPC 请求")
432
+ }
433
+
434
+ // 显式字段优先于环境变量。
435
+ if _, err := (&DshEngine{BinPath: bin, Profile: dshSDKProfile}).Complete(context.Background(), Request{
436
+ Messages: []Message{{Role: "user", Content: "hi"}},
437
+ }); err != nil {
438
+ t.Fatalf("Complete: %v", err)
439
+ }
440
+ if args := readFileString(t, argsLog); !strings.Contains(args, "--profile sdk") {
441
+ t.Errorf("显式 Profile=sdk 应压过 env: %q", args)
442
+ }
443
+ }
444
+
445
+ // Profile 显式给 "sdk" 但 SDK 起不来 → 回退 `--profile headless`(不是把 sdk 再喂一遍)。
446
+ func TestDshSDKExplicitProfileFallsBackToHeadless(t *testing.T) {
447
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
448
+ dir := t.TempDir()
449
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{SilentSDK: true, HeadlessBody: "echo '回退正文'"})
450
+
451
+ resp, err := (&DshEngine{BinPath: bin, Profile: dshSDKProfile}).Complete(context.Background(), Request{
452
+ Messages: []Message{{Role: "user", Content: "hi"}},
453
+ })
454
+ if err != nil {
455
+ t.Fatalf("Complete: %v", err)
456
+ }
457
+ if resp.Text != "回退正文" {
458
+ t.Errorf("Text = %q want 回退正文", resp.Text)
459
+ }
460
+ args := readFileString(t, argsLog)
461
+ if !strings.Contains(args, "--profile headless") {
462
+ t.Errorf("回退应用 headless(不是 sdk): %q", args)
463
+ }
464
+ if strings.Count(args, "--profile sdk") != 1 {
465
+ t.Errorf("sdk 只该试一次: %q", args)
466
+ }
467
+ }
468
+
469
+ // dshResolveRoute:`route/model` / `dsh/route/model` / 裸 id / 空(回落配置层)。
470
+ func TestDshResolveRoute(t *testing.T) {
471
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
472
+ cases := []struct {
473
+ name string
474
+ reqModel string
475
+ wantProv string
476
+ wantModel string
477
+ }{
478
+ {"空 → 配置层", "", "modelverse", "deepseek-v4.1-flash"},
479
+ {"ListModels 形态", "modelverse/deepseek-v4-pro-0813", "modelverse", "deepseek-v4-pro-0813"},
480
+ {"带引擎前缀", "dsh/modelverse/deepseek-v4-pro-0813", "modelverse", "deepseek-v4-pro-0813"},
481
+ {"裸 id → 用配置层的 provider", "deepseek-v4-pro-0813", "modelverse", "deepseek-v4-pro-0813"},
482
+ {"引擎前缀 + 裸 id", "dsh/deepseek-v4-pro-0813", "modelverse", "deepseek-v4-pro-0813"},
483
+ }
484
+ for _, c := range cases {
485
+ p, m := dshResolveRoute(c.reqModel)
486
+ if p != c.wantProv || m != c.wantModel {
487
+ t.Errorf("%s: dshResolveRoute(%q) = (%q,%q) want (%q,%q)",
488
+ c.name, c.reqModel, p, m, c.wantProv, c.wantModel)
489
+ }
490
+ }
491
+ }
492
+
493
+ // 读不到 agent-default-model(provider/model 是 initialize 的必填项)→ 判为通道不可用 → 回退。
494
+ //
495
+ // 注意这条路上**不会**起 SDK 进程:provider/model 是 initialize 的必填项,拼不出请求
496
+ // 就没必要 spawn(也避免了「先起进程再立刻杀」)。
497
+ func TestDshSDKNoConfiguredRouteFallsBack(t *testing.T) {
498
+ t.Setenv("DSH_HOME", t.TempDir()) // 没有 settings.yaml
499
+ dir := t.TempDir()
500
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{HeadlessBody: "echo 'headless 兜底'"})
501
+
502
+ old := stderr
503
+ var buf strings.Builder
504
+ stderr = &buf
505
+ defer func() { stderr = old }()
506
+
507
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
508
+ Messages: []Message{{Role: "user", Content: "hi"}},
509
+ })
510
+ if err != nil {
511
+ t.Fatalf("Complete: %v", err)
512
+ }
513
+ if resp.Text != "headless 兜底" {
514
+ t.Errorf("Text = %q want headless 兜底", resp.Text)
515
+ }
516
+ if args := readFileString(t, argsLog); strings.Contains(args, "--profile sdk") {
517
+ t.Errorf("拼不出 initialize 参数时不该起 SDK 进程: %q", args)
518
+ }
519
+ if warn := buf.String(); !strings.Contains(warn, "SDK 通道不可用") {
520
+ t.Errorf("回退应有明确告警: %q", warn)
521
+ }
522
+ }
523
+
524
+ // 没有 turn/end 时用 session.status:idle 兜底收尾(协议里两条都可能只来一条)。
525
+ func TestDshSDKStatusIdleFallback(t *testing.T) {
526
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
527
+ dir := t.TempDir()
528
+ events := ` printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.event\",\"params\":{\"sessionId\":\"$sid\",\"event\":{\"type\":\"assistant/message\",\"data\":{\"turn\":$turn,\"step\":1,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"兜底正文\"}]}}}}}"
529
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.status\",\"params\":{\"sessionId\":\"$sid\",\"status\":\"running\"}}"
530
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"method\":\"session.status\",\"params\":{\"sessionId\":\"$sid\",\"status\":\"idle\"}}"` // 故意不给 turn/end
531
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{PromptEvents: events})
532
+
533
+ resp, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
534
+ Messages: []Message{{Role: "user", Content: "hi"}},
535
+ })
536
+ if err != nil {
537
+ t.Fatalf("Complete: %v", err)
538
+ }
539
+ if resp.Text != "兜底正文" {
540
+ t.Errorf("Text = %q want 兜底正文", resp.Text)
541
+ }
542
+ }
543
+
544
+ // ctx 取消:立刻返回 ctx 错误,且收尾不卡(读线程不能卡在投递上)。
545
+ func TestDshSDKContextCancel(t *testing.T) {
546
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
547
+ dir := t.TempDir()
548
+ // PromptEvents = ":" 即「只回 prompt 响应,之后什么都不推」→ 模拟一直不结束的一轮。
549
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{PromptEvents: ":"})
550
+
551
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
552
+ defer cancel()
553
+ start := time.Now()
554
+ _, err := (&DshEngine{BinPath: bin}).Stream(ctx, Request{
555
+ Messages: []Message{{Role: "user", Content: "hi"}},
556
+ }, nil)
557
+ if err == nil {
558
+ t.Fatal("ctx 超时应报错")
559
+ }
560
+ if !errors.Is(err, context.DeadlineExceeded) {
561
+ t.Errorf("错误应体现 ctx 超时: %v", err)
562
+ }
563
+ if d := time.Since(start); d > 30*time.Second {
564
+ t.Errorf("收尾过慢(%v),读线程/进程收尾可能卡住", d)
565
+ }
566
+ }
567
+
568
+ // 常驻会话(Request.Append):同一进程内对同一 sessionId 继续 prompt 即续接同一会话。
569
+ //
570
+ // 这是 dsh 上唯一真正可用的续接形态 —— 官方 Python SDK 文档:「reuse a harness, home,
571
+ // and id only to continue the same durable conversation」。
572
+ func TestDshSDKAppendContinuesSameSession(t *testing.T) {
573
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
574
+ dir := t.TempDir()
575
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
576
+
577
+ appendCh := make(chan string, 4)
578
+ appendCh <- "第一轮追加"
579
+ close(appendCh)
580
+
581
+ var mu sync.Mutex
582
+ var events []StreamEvent
583
+ res, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
584
+ Messages: []Message{{Role: "user", Content: "看看目录"}},
585
+ Append: appendCh,
586
+ }, func(ev StreamEvent) {
587
+ mu.Lock()
588
+ events = append(events, ev)
589
+ mu.Unlock()
590
+ })
591
+ if err != nil {
592
+ t.Fatalf("Stream: %v", err)
593
+ }
594
+
595
+ // 两次 session/prompt,且**同一个 sessionId**(换了 id 就是两个独立会话了)。
596
+ lines := strings.Split(strings.TrimSpace(readFileString(t, stdinLog)), "\n")
597
+ var promptSIDs []string
598
+ for _, ln := range lines {
599
+ if !strings.Contains(ln, "session/prompt") {
600
+ continue
601
+ }
602
+ m := regexp.MustCompile(`"sessionId":"([^"]*)"`).FindStringSubmatch(ln)
603
+ if len(m) != 2 {
604
+ t.Fatalf("解析 sessionId 失败: %s", ln)
605
+ }
606
+ promptSIDs = append(promptSIDs, m[1])
607
+ }
608
+ if len(promptSIDs) != 2 {
609
+ t.Fatalf("session/prompt 次数 = %d want 2(首轮 + 一条追加)", len(promptSIDs))
610
+ }
611
+ if promptSIDs[0] != promptSIDs[1] {
612
+ t.Errorf("追加轮换了 sessionId(%q → %q)—— 那就不是同一会话了", promptSIDs[0], promptSIDs[1])
613
+ }
614
+ if res.SessionID != promptSIDs[0] {
615
+ t.Errorf("Response.SessionID = %q want %q(--append <session_id> 要靠它定位)", res.SessionID, promptSIDs[0])
616
+ }
617
+
618
+ // 每轮一条 turn_end(常驻看门狗靠它判断轮次边界),共 2 条。
619
+ mu.Lock()
620
+ defer mu.Unlock()
621
+ var turnEnds int
622
+ for _, ev := range events {
623
+ if ev.Kind == KindTurnEnd {
624
+ turnEnds++
625
+ }
626
+ }
627
+ if turnEnds != 2 {
628
+ t.Errorf("KindTurnEnd = %d want 2(每轮一条)", turnEnds)
629
+ }
630
+ // 工具调用跨轮累加(与 claude/codebuddy 的 acc.Tools 同语义)。
631
+ if len(res.Tools) != 2 {
632
+ t.Errorf("Tools = %d want 2(每轮一次 bash)", len(res.Tools))
633
+ }
634
+ // 正文取**最后一轮**(与 claude/codebuddy 的 finalizeStreamText 同语义)。
635
+ if res.Text != "目录里有 a.txt" {
636
+ t.Errorf("Text = %q want 目录里有 a.txt", res.Text)
637
+ }
638
+ if strings.Count(res.Thinking, "先看看仓库") != 2 {
639
+ t.Errorf("Thinking 应跨轮累加: %q", res.Text)
640
+ }
641
+ }
642
+
643
+ // 两轮之间的 session.status:idle **不能**把常驻会话掐掉(那是轮次边界,不是会话终点)。
644
+ func TestDshSDKAppendSurvivesIdleBetweenRounds(t *testing.T) {
645
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
646
+ dir := t.TempDir()
647
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{}) // 默认事件里 turn/end 之后带 status:idle
648
+
649
+ appendCh := make(chan string, 4)
650
+ appendCh <- "再来一轮"
651
+ close(appendCh)
652
+
653
+ if _, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
654
+ Messages: []Message{{Role: "user", Content: "hi"}},
655
+ Append: appendCh,
656
+ }, nil); err != nil {
657
+ t.Fatalf("Stream: %v", err)
658
+ }
659
+ if got := strings.Count(readFileString(t, stdinLog), "session/prompt"); got != 2 {
660
+ t.Errorf("session/prompt = %d want 2(idle 不该让会话在第一轮后就收尾)", got)
661
+ }
662
+ }
663
+
664
+ // 常驻会话需要 SDK 通道:通道不可用时**明确报错**,不静默把追加消息丢掉。
665
+ func TestDshSDKAppendRequiresSDKChannel(t *testing.T) {
666
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
667
+ dir := t.TempDir()
668
+ bin, argsLog, _ := dshSDKStub(t, dir, dshSDKStubOpts{SilentSDK: true})
669
+
670
+ appendCh := make(chan string, 1)
671
+ close(appendCh)
672
+ _, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
673
+ Messages: []Message{{Role: "user", Content: "hi"}},
674
+ Append: appendCh,
675
+ }, nil)
676
+ if err == nil {
677
+ t.Fatal("SDK 通道不可用时,常驻会话应明确报错")
678
+ }
679
+ if !strings.Contains(err.Error(), "常驻会话") || !strings.Contains(err.Error(), "SDK 通道") {
680
+ t.Errorf("错误应说明常驻会话需要 SDK 通道: %v", err)
681
+ }
682
+ // 关键:不能拿 headless 顶替(那会把追加消息静默丢掉)。
683
+ if args := readFileString(t, argsLog); strings.Contains(args, "headless") {
684
+ t.Errorf("常驻会话不该回退 headless: %q", args)
685
+ }
686
+ }
687
+
688
+ // 追加消息为空/纯空白 → 不排新轮(避免空 prompt 被服务端拒)。
689
+ func TestDshSDKAppendSkipsBlank(t *testing.T) {
690
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
691
+ dir := t.TempDir()
692
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
693
+
694
+ appendCh := make(chan string, 4)
695
+ appendCh <- " "
696
+ close(appendCh)
697
+
698
+ if _, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
699
+ Messages: []Message{{Role: "user", Content: "hi"}},
700
+ Append: appendCh,
701
+ }, nil); err != nil {
702
+ t.Fatalf("Stream: %v", err)
703
+ }
704
+ if got := strings.Count(readFileString(t, stdinLog), "session/prompt"); got != 1 {
705
+ t.Errorf("session/prompt = %d want 1(空白追加不排新轮)", got)
706
+ }
707
+ }
708
+
709
+ // ── 小工具 ────────────────────────────────────────────────────
710
+
711
+ func readFileString(t *testing.T, p string) string {
712
+ t.Helper()
713
+ b, err := os.ReadFile(p)
714
+ if err != nil {
715
+ t.Fatalf("读 %s: %v", p, err)
716
+ }
717
+ return string(b)
718
+ }
719
+
720
+ func kindsToStrings(kinds []StreamEventKind) []string {
721
+ out := make([]string, 0, len(kinds))
722
+ for _, k := range kinds {
723
+ out = append(out, string(k))
724
+ }
725
+ return out
726
+ }