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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// ask_test.go - CLI 层测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:
|
|
6
|
+
// - collectPrompt 输入收集(args / --file / stdin 组合)
|
|
7
|
+
// - 端到端:fake 引擎注册 + cobra 命令执行 + 默认 json 输出
|
|
8
|
+
// - 根命令提问(-p / 位置参数)
|
|
9
|
+
// - --tools 解析(off / on / 白名单 / 非法值)
|
|
10
|
+
// - --engines flag(默认 JSON 数组;--json 兼容)
|
|
11
|
+
// - 子命令已删除(ask / engines / version / completion 均不存在)
|
|
12
|
+
// - 引擎/格式参数校验错误 → exit 2
|
|
13
|
+
//
|
|
14
|
+
// 默认值契约(newAskOptions 与 bindAskFlags 双处维护,须一致):
|
|
15
|
+
// engine=codebuddy, output=json, timeout=600s, tools=off
|
|
16
|
+
|
|
17
|
+
import (
|
|
18
|
+
"bytes"
|
|
19
|
+
"context"
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"errors"
|
|
22
|
+
"os"
|
|
23
|
+
"path/filepath"
|
|
24
|
+
"strings"
|
|
25
|
+
"testing"
|
|
26
|
+
"time"
|
|
27
|
+
|
|
28
|
+
"github.com/spf13/cobra"
|
|
29
|
+
|
|
30
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
31
|
+
"github.com/darren/magic-agent/internal/config"
|
|
32
|
+
"github.com/darren/magic-agent/internal/session"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// TestMain 隔离用户真实配置与会话登记表。
|
|
36
|
+
//
|
|
37
|
+
// prepareAsk 会读配置文件取「默认系统提示词」(~/.config/magic-agent/config.json),
|
|
38
|
+
// 每次调用还会往会话登记表(~/.magic-agent/sessions/)落一条记录 —— 不隔离的话
|
|
39
|
+
// 本机那份配置会渗进断言,测试还会往用户真实登记表里写垃圾。
|
|
40
|
+
// 需要真配置/真登记表的用例自己用 t.Setenv 覆盖。
|
|
41
|
+
func TestMain(m *testing.M) {
|
|
42
|
+
_ = os.Setenv(config.EnvPath, filepath.Join(os.TempDir(), "magic-agent-test-absent", "config.json"))
|
|
43
|
+
// 登记表目录用 /tmp 下的**短**路径:常驻会话的追加入口是 unix socket,
|
|
44
|
+
// 路径有 104 字节上限,而 macOS 的 TMPDIR 是 /var/folders/... 那种长路径。
|
|
45
|
+
sessDir, err := os.MkdirTemp("/tmp", "mas-cli-test-")
|
|
46
|
+
if err != nil {
|
|
47
|
+
sessDir, err = os.MkdirTemp("", "mas-cli-test-")
|
|
48
|
+
}
|
|
49
|
+
if err == nil {
|
|
50
|
+
_ = os.Setenv(session.EnvDir, sessDir)
|
|
51
|
+
}
|
|
52
|
+
code := m.Run()
|
|
53
|
+
if err == nil {
|
|
54
|
+
_ = os.RemoveAll(sessDir)
|
|
55
|
+
}
|
|
56
|
+
os.Exit(code)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// stringEngine 最简单的假引擎:固定返回文本。
|
|
60
|
+
type stringEngine struct {
|
|
61
|
+
name string
|
|
62
|
+
text string
|
|
63
|
+
err error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *stringEngine) Name() string { return s.name }
|
|
67
|
+
func (s *stringEngine) Detect() (bool, string) { return true, "fake://" + s.name }
|
|
68
|
+
func (s *stringEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
|
|
69
|
+
if s.err != nil {
|
|
70
|
+
return agent.Response{}, s.err
|
|
71
|
+
}
|
|
72
|
+
return agent.Response{Text: s.text, Model: "fake-model", Latency: 5_000_000}, nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// streamingEngine 同时实现 Engine + Streamer 的假引擎。
|
|
76
|
+
type streamingEngine struct {
|
|
77
|
+
name string
|
|
78
|
+
text string
|
|
79
|
+
thinking string
|
|
80
|
+
err error
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (s *streamingEngine) Name() string { return s.name }
|
|
84
|
+
func (s *streamingEngine) Detect() (bool, string) { return true, "fake://" + s.name }
|
|
85
|
+
func (s *streamingEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
|
|
86
|
+
if s.err != nil {
|
|
87
|
+
return agent.Response{}, s.err
|
|
88
|
+
}
|
|
89
|
+
return agent.Response{Text: s.text, Model: "fake-model", Latency: 5_000_000}, nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func (s *streamingEngine) Stream(ctx context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
|
|
93
|
+
if s.err != nil {
|
|
94
|
+
return agent.StreamResult{}, s.err
|
|
95
|
+
}
|
|
96
|
+
if s.thinking != "" {
|
|
97
|
+
onEvent(agent.StreamEvent{Kind: agent.KindThinking, Text: s.thinking})
|
|
98
|
+
}
|
|
99
|
+
onEvent(agent.StreamEvent{Kind: agent.KindText, Text: s.text})
|
|
100
|
+
return agent.StreamResult{
|
|
101
|
+
Response: agent.Response{Engine: s.name, Text: s.text, Model: "fake-model", Latency: 5_000_000},
|
|
102
|
+
Thinking: s.thinking,
|
|
103
|
+
}, nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// capturingEngine 捕获收到的 prompt 与 tools 模式,供组合输入断言。
|
|
107
|
+
type capturingEngine struct {
|
|
108
|
+
name string
|
|
109
|
+
lastPrompt string
|
|
110
|
+
lastTools agent.ToolsMode
|
|
111
|
+
lastSchema *agent.JSONSchema
|
|
112
|
+
lastMaxTok int
|
|
113
|
+
lastTemp *float64
|
|
114
|
+
lastSessionID string
|
|
115
|
+
lastContinue bool
|
|
116
|
+
lastSystem string
|
|
117
|
+
lastAttach []agent.Attachment
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func (c *capturingEngine) Name() string { return c.name }
|
|
121
|
+
func (c *capturingEngine) Detect() (bool, string) { return true, "fake://" + c.name }
|
|
122
|
+
func (c *capturingEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
|
|
123
|
+
if len(req.Messages) > 0 {
|
|
124
|
+
c.lastPrompt = req.Messages[len(req.Messages)-1].Content
|
|
125
|
+
}
|
|
126
|
+
c.lastTools = req.Tools
|
|
127
|
+
c.lastSchema = req.JSONSchema
|
|
128
|
+
c.lastMaxTok = req.MaxTokens
|
|
129
|
+
c.lastTemp = req.Temperature
|
|
130
|
+
c.lastSessionID = req.SessionID
|
|
131
|
+
c.lastContinue = req.Continue
|
|
132
|
+
c.lastSystem = req.SystemPrompt
|
|
133
|
+
c.lastAttach = req.Attachments
|
|
134
|
+
return agent.Response{Text: "captured", Model: "fake-model", Latency: 1_000_000}, nil
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── collectPrompt ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
func TestCollectPromptFromArgs(t *testing.T) {
|
|
140
|
+
parts, err := collectPrompt(strings.NewReader(""), []string{"你好", "世界"}, "")
|
|
141
|
+
if err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
if len(parts) != 1 || parts[0] != "你好 世界" {
|
|
145
|
+
t.Errorf("parts = %v", parts)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestCollectPromptFromFile(t *testing.T) {
|
|
150
|
+
dir := t.TempDir()
|
|
151
|
+
f := filepath.Join(dir, "p.txt")
|
|
152
|
+
if err := os.WriteFile(f, []byte("文件内容"), 0o644); err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
parts, err := collectPrompt(strings.NewReader(""), nil, f)
|
|
156
|
+
if err != nil {
|
|
157
|
+
t.Fatal(err)
|
|
158
|
+
}
|
|
159
|
+
if len(parts) != 1 || parts[0] != "文件内容" {
|
|
160
|
+
t.Errorf("parts = %v", parts)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestCollectPromptFilePlusArgs(t *testing.T) {
|
|
165
|
+
dir := t.TempDir()
|
|
166
|
+
f := filepath.Join(dir, "p.txt")
|
|
167
|
+
if err := os.WriteFile(f, []byte("上下文"), 0o644); err != nil {
|
|
168
|
+
t.Fatal(err)
|
|
169
|
+
}
|
|
170
|
+
parts, err := collectPrompt(strings.NewReader(""), []string{"问题"}, f)
|
|
171
|
+
if err != nil {
|
|
172
|
+
t.Fatal(err)
|
|
173
|
+
}
|
|
174
|
+
if len(parts) != 2 || parts[0] != "上下文" || parts[1] != "问题" {
|
|
175
|
+
t.Errorf("parts = %v (want 文件在前、参数在后)", parts)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func TestCollectPromptFileMissing(t *testing.T) {
|
|
180
|
+
if _, err := collectPrompt(strings.NewReader(""), nil, "/no/such/file.txt"); err == nil {
|
|
181
|
+
t.Error("expected error for missing file")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 端到端命令执行 ────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
// runAskCmd 用给定 args 跑根命令,返回 (stdout, stderr, error)。
|
|
188
|
+
func runAskCmd(t *testing.T, stdin string, args ...string) (string, string, error) {
|
|
189
|
+
t.Helper()
|
|
190
|
+
cmd := NewRootCommand()
|
|
191
|
+
var out, errBuf bytes.Buffer
|
|
192
|
+
cmd.SetOut(&out)
|
|
193
|
+
cmd.SetErr(&errBuf)
|
|
194
|
+
cmd.SetIn(strings.NewReader(stdin))
|
|
195
|
+
cmd.SetArgs(args)
|
|
196
|
+
err := cmd.Execute()
|
|
197
|
+
return out.String(), errBuf.String(), err
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// registerFake 注册假引擎并登记清理。
|
|
201
|
+
func registerFake(e agent.Engine) {
|
|
202
|
+
agent.Register(e)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func TestAskEndToEndText(t *testing.T) {
|
|
206
|
+
registerFake(&stringEngine{name: "fake-echo", text: "echoed"})
|
|
207
|
+
|
|
208
|
+
// 默认输出 json;-o text 显式要纯文本。
|
|
209
|
+
stdout, _, err := runAskCmd(t, "", "-e", "fake-echo", "-o", "text", "你好")
|
|
210
|
+
if err != nil {
|
|
211
|
+
t.Fatalf("ask: %v", err)
|
|
212
|
+
}
|
|
213
|
+
if strings.TrimSpace(stdout) != "echoed" {
|
|
214
|
+
t.Errorf("stdout = %q", stdout)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func TestAskEndToEndJSON(t *testing.T) {
|
|
219
|
+
registerFake(&stringEngine{name: "fake-json", text: "the answer"})
|
|
220
|
+
|
|
221
|
+
// 默认(不传 -o)就是 json。
|
|
222
|
+
stdout, _, err := runAskCmd(t, "", "-e", "fake-json", "hi")
|
|
223
|
+
if err != nil {
|
|
224
|
+
t.Fatalf("ask: %v", err)
|
|
225
|
+
}
|
|
226
|
+
for _, want := range []string{
|
|
227
|
+
`"engine":"fake-json"`,
|
|
228
|
+
`"model":"fake-model"`,
|
|
229
|
+
`"attempts":1`,
|
|
230
|
+
`"latency_ms":5`,
|
|
231
|
+
`"text":"the answer"`,
|
|
232
|
+
} {
|
|
233
|
+
if !strings.Contains(stdout, want) {
|
|
234
|
+
t.Errorf("stdout missing %s: %s", want, stdout)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 根命令直接提问(唯一形态,子命令已删除)。
|
|
240
|
+
func TestRootShorthandAsk(t *testing.T) {
|
|
241
|
+
registerFake(&stringEngine{name: "fake-root", text: "root answer"})
|
|
242
|
+
|
|
243
|
+
// 位置参数形态
|
|
244
|
+
stdout, _, err := runAskCmd(t, "", "-e", "fake-root", "你好")
|
|
245
|
+
if err != nil {
|
|
246
|
+
t.Fatalf("root ask: %v", err)
|
|
247
|
+
}
|
|
248
|
+
if !strings.Contains(stdout, `"text":"root answer"`) {
|
|
249
|
+
t.Errorf("root positional stdout = %q", stdout)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -p 提示词形态(-p = --prompt)
|
|
253
|
+
stdout2, _, err := runAskCmd(t, "", "-e", "fake-root", "-p", "pipe问题")
|
|
254
|
+
if err != nil {
|
|
255
|
+
t.Fatalf("root -p ask: %v", err)
|
|
256
|
+
}
|
|
257
|
+
if !strings.Contains(stdout2, `"text":"root answer"`) {
|
|
258
|
+
t.Errorf("root -p stdout = %q", stdout2)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -p 与位置参数组合:-p 在前、args 在后;--tools 透传到 Request。
|
|
263
|
+
func TestPromptFlagCombinedWithArgs(t *testing.T) {
|
|
264
|
+
capEng := &capturingEngine{name: "fake-cap"}
|
|
265
|
+
registerFake(capEng)
|
|
266
|
+
|
|
267
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-cap", "-p", "主问题", "附加", "上下文")
|
|
268
|
+
if err != nil {
|
|
269
|
+
t.Fatalf("ask: %v", err)
|
|
270
|
+
}
|
|
271
|
+
got := capEng.lastPrompt
|
|
272
|
+
if !strings.HasPrefix(got, "主问题") || !strings.Contains(got, "附加 上下文") {
|
|
273
|
+
t.Errorf("prompt = %q, want -p 在前 + args 在后", got)
|
|
274
|
+
}
|
|
275
|
+
// 默认 tools=off 透传
|
|
276
|
+
if !agent.ToolsIsOff(capEng.lastTools) {
|
|
277
|
+
t.Errorf("default tools should be off, got %#v", capEng.lastTools)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --tools on / 白名单透传到引擎。
|
|
282
|
+
func TestToolsPassthrough(t *testing.T) {
|
|
283
|
+
capEng := &capturingEngine{name: "fake-tools"}
|
|
284
|
+
registerFake(capEng)
|
|
285
|
+
|
|
286
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-tools", "--tools", "Bash,Read", "hi"); err != nil {
|
|
287
|
+
t.Fatal(err)
|
|
288
|
+
}
|
|
289
|
+
al := agent.ToolsAllowlistOf(capEng.lastTools)
|
|
290
|
+
if len(al) != 2 || al[0] != "Bash" || al[1] != "Read" {
|
|
291
|
+
t.Errorf("allowlist = %v", al)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
capEng.lastTools = nil
|
|
295
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-tools", "--tools", "on", "hi"); err != nil {
|
|
296
|
+
t.Fatal(err)
|
|
297
|
+
}
|
|
298
|
+
if !agent.ToolsIsOn(capEng.lastTools) {
|
|
299
|
+
t.Errorf("tools=on should pass through, got %#v", capEng.lastTools)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --session 会话续接 id 透传到引擎 Request;缺省为空 = 新会话。
|
|
304
|
+
func TestSessionPassthrough(t *testing.T) {
|
|
305
|
+
capEng := &capturingEngine{name: "fake-sess"}
|
|
306
|
+
registerFake(capEng)
|
|
307
|
+
|
|
308
|
+
// 显式 --session abc → Request.SessionID == "abc"
|
|
309
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sess", "--session", "abc", "接着说"); err != nil {
|
|
310
|
+
t.Fatal(err)
|
|
311
|
+
}
|
|
312
|
+
if capEng.lastSessionID != "abc" {
|
|
313
|
+
t.Errorf("lastSessionID = %q, want abc", capEng.lastSessionID)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 缺省 → 空串(新会话)
|
|
317
|
+
capEng.lastSessionID = "sentinel"
|
|
318
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sess", "hi"); err != nil {
|
|
319
|
+
t.Fatal(err)
|
|
320
|
+
}
|
|
321
|
+
if capEng.lastSessionID != "" {
|
|
322
|
+
t.Errorf("default lastSessionID = %q, want empty (new session)", capEng.lastSessionID)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 纯空白值视同缺省 → 空串
|
|
326
|
+
capEng.lastSessionID = "sentinel"
|
|
327
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sess", "--session", " ", "hi"); err != nil {
|
|
328
|
+
t.Fatal(err)
|
|
329
|
+
}
|
|
330
|
+
if capEng.lastSessionID != "" {
|
|
331
|
+
t.Errorf("blank lastSessionID = %q, want empty (trimmed)", capEng.lastSessionID)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// -c/--continue 透传到引擎 Request;缺省 false = 新会话;
|
|
336
|
+
// --session 与 -c 同时给时两者都透传(引擎侧 SessionID 优先)。
|
|
337
|
+
func TestContinuePassthrough(t *testing.T) {
|
|
338
|
+
capEng := &capturingEngine{name: "fake-cont"}
|
|
339
|
+
registerFake(capEng)
|
|
340
|
+
|
|
341
|
+
// -c → Continue == true
|
|
342
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cont", "-c", "接着说"); err != nil {
|
|
343
|
+
t.Fatal(err)
|
|
344
|
+
}
|
|
345
|
+
if !capEng.lastContinue {
|
|
346
|
+
t.Errorf("lastContinue = false, want true (-c)")
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 长形式 --continue 等价
|
|
350
|
+
capEng.lastContinue = false
|
|
351
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cont", "--continue", "hi"); err != nil {
|
|
352
|
+
t.Fatal(err)
|
|
353
|
+
}
|
|
354
|
+
if !capEng.lastContinue {
|
|
355
|
+
t.Errorf("--continue long form not captured")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 缺省 → false(新会话)
|
|
359
|
+
capEng.lastContinue = true
|
|
360
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cont", "hi"); err != nil {
|
|
361
|
+
t.Fatal(err)
|
|
362
|
+
}
|
|
363
|
+
if capEng.lastContinue {
|
|
364
|
+
t.Errorf("default lastContinue = true, want false (new session)")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --session 与 -c 同时给:CLI 层两者都透传,优先级由引擎侧保证
|
|
368
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-cont", "--session", "abc", "-c", "hi"); err != nil {
|
|
369
|
+
t.Fatal(err)
|
|
370
|
+
}
|
|
371
|
+
if capEng.lastSessionID != "abc" || !capEng.lastContinue {
|
|
372
|
+
t.Errorf("session=%q continue=%v, want abc/true", capEng.lastSessionID, capEng.lastContinue)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --tools 参数解析:off / on / 白名单 / 非法值。
|
|
377
|
+
// 断言走 agent 包导出的 ToolsIsOff/ToolsIsOn/ToolsAllowlistOf
|
|
378
|
+
// (toolsModeImpl 未导出,跨包接口断言其小写方法不可行)。
|
|
379
|
+
func TestParseToolsMode(t *testing.T) {
|
|
380
|
+
m1, err1 := parseToolsMode("off")
|
|
381
|
+
if err1 != nil {
|
|
382
|
+
t.Fatalf("off: %v", err1)
|
|
383
|
+
}
|
|
384
|
+
if !agent.ToolsIsOff(m1) {
|
|
385
|
+
t.Errorf("off should parse to off mode, got %#v", m1)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
m2, err2 := parseToolsMode("on")
|
|
389
|
+
if err2 != nil {
|
|
390
|
+
t.Fatalf("on: %v", err2)
|
|
391
|
+
}
|
|
392
|
+
if agent.ToolsIsOff(m2) || !agent.ToolsIsOn(m2) {
|
|
393
|
+
t.Errorf("on should parse to on mode, got %#v", m2)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
m3, err3 := parseToolsMode("Bash, Read")
|
|
397
|
+
if err3 != nil {
|
|
398
|
+
t.Fatalf("allowlist: %v", err3)
|
|
399
|
+
}
|
|
400
|
+
if agent.ToolsIsOff(m3) || agent.ToolsIsOn(m3) {
|
|
401
|
+
t.Fatalf("allowlist mode wrong: %#v", m3)
|
|
402
|
+
}
|
|
403
|
+
if al := agent.ToolsAllowlistOf(m3); len(al) != 2 || al[0] != "Bash" || al[1] != "Read" {
|
|
404
|
+
t.Errorf("allowlist = %v", al)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if _, err := parseToolsMode(" , "); err == nil {
|
|
408
|
+
t.Error("comma-only value should error")
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 默认值契约:engine=codebuddy / output=json / timeout=600s。
|
|
413
|
+
func TestDefaults(t *testing.T) {
|
|
414
|
+
opts := newAskOptions()
|
|
415
|
+
if opts.engine != "codebuddy" {
|
|
416
|
+
t.Errorf("default engine = %q, want codebuddy", opts.engine)
|
|
417
|
+
}
|
|
418
|
+
if opts.output != "json" {
|
|
419
|
+
t.Errorf("default output = %q, want json", opts.output)
|
|
420
|
+
}
|
|
421
|
+
if opts.timeout != 600_000_000_000 { // 600s
|
|
422
|
+
t.Errorf("default timeout = %v, want 600s", opts.timeout)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func TestAskStdinPipe(t *testing.T) {
|
|
427
|
+
registerFake(&stringEngine{name: "fake-stdin", text: "ok"})
|
|
428
|
+
|
|
429
|
+
stdout, _, err := runAskCmd(t, "来自管道的问题", "-e", "fake-stdin", "-o", "text")
|
|
430
|
+
if err != nil {
|
|
431
|
+
t.Fatalf("ask: %v", err)
|
|
432
|
+
}
|
|
433
|
+
if strings.TrimSpace(stdout) != "ok" {
|
|
434
|
+
t.Errorf("stdout = %q", stdout)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
func TestAskUnknownEngine(t *testing.T) {
|
|
439
|
+
_, _, err := runAskCmd(t, "", "-e", "nope", "hi")
|
|
440
|
+
if err == nil {
|
|
441
|
+
t.Fatal("expected error")
|
|
442
|
+
}
|
|
443
|
+
if _, ok := err.(*usageError); !ok {
|
|
444
|
+
t.Errorf("error should be usageError (exit 2), got %T: %v", err, err)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// streamTimeoutEngine 记录流式调用收到的 req.Timeout(回归:-t 必须透传到引擎)。
|
|
449
|
+
type streamTimeoutEngine struct {
|
|
450
|
+
name string
|
|
451
|
+
lastTimeout time.Duration
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
func (s *streamTimeoutEngine) Name() string { return s.name }
|
|
455
|
+
func (s *streamTimeoutEngine) Detect() (bool, string) { return true, "fake://" + s.name }
|
|
456
|
+
func (s *streamTimeoutEngine) Complete(context.Context, agent.Request) (agent.Response, error) {
|
|
457
|
+
return agent.Response{Text: "ok"}, nil
|
|
458
|
+
}
|
|
459
|
+
func (s *streamTimeoutEngine) Stream(_ context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
|
|
460
|
+
s.lastTimeout = req.Timeout
|
|
461
|
+
onEvent(agent.StreamEvent{Kind: agent.KindText, Text: "ok"})
|
|
462
|
+
return agent.StreamResult{Response: agent.Response{Engine: s.name, Text: "ok"}}, nil
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 回归(2026-09-24):流式路径必须把调用方**显式**给的 -t 透传给引擎。
|
|
466
|
+
//
|
|
467
|
+
// 以前流式路径从不设 req.Timeout → 引擎只能用自己的默认值(arkclaw 是 3 分钟),
|
|
468
|
+
// 于是桌面壳传的 `-t 600s` 被静默忽略;而网关侧带工具循环的任务要 5 分钟
|
|
469
|
+
// (实测「生成周报」304s 才回)→ 3 分钟必被砍,界面上就是「什么都没显示 · 调用失败」。
|
|
470
|
+
func TestStreamPassesExplicitTimeoutToEngine(t *testing.T) {
|
|
471
|
+
eng := &streamTimeoutEngine{name: "fake-stream-timeout"}
|
|
472
|
+
registerFake(eng)
|
|
473
|
+
|
|
474
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-stream-timeout", "--stream", "-t", "900s", "hi"); err != nil {
|
|
475
|
+
t.Fatalf("--stream -t 900s: %v", err)
|
|
476
|
+
}
|
|
477
|
+
if eng.lastTimeout != 900*time.Second {
|
|
478
|
+
t.Errorf("引擎收到的 req.Timeout = %v want 15m(显式 -t 未透传)", eng.lastTimeout)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 没给 -t → 不透传:别去覆盖引擎自己的默认(如 llm 的条目级 timeout、openclaw 的 600s)。
|
|
482
|
+
eng.lastTimeout = -1
|
|
483
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-stream-timeout", "--stream", "hi"); err != nil {
|
|
484
|
+
t.Fatalf("--stream: %v", err)
|
|
485
|
+
}
|
|
486
|
+
if eng.lastTimeout != 0 {
|
|
487
|
+
t.Errorf("未给 -t 时 req.Timeout = %v want 0(不该覆盖引擎默认)", eng.lastTimeout)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
func TestAskBadFormat(t *testing.T) {
|
|
492
|
+
_, _, err := runAskCmd(t, "", "-e", "claude", "-o", "yaml", "hi")
|
|
493
|
+
if err == nil {
|
|
494
|
+
t.Fatal("expected error")
|
|
495
|
+
}
|
|
496
|
+
if _, ok := err.(*usageError); !ok {
|
|
497
|
+
t.Errorf("error should be usageError, got %T", err)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
func TestAskEmptyPrompt(t *testing.T) {
|
|
502
|
+
_, _, err := runAskCmd(t, "", "-e", "claude")
|
|
503
|
+
if err == nil {
|
|
504
|
+
t.Fatal("expected error")
|
|
505
|
+
}
|
|
506
|
+
if _, ok := err.(*usageError); !ok {
|
|
507
|
+
t.Errorf("error should be usageError, got %T", err)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --json-schema 解析 + 透传到 Request.JSONSchema。
|
|
512
|
+
func TestJSONSchemaPassesThrough(t *testing.T) {
|
|
513
|
+
capEng := &capturingEngine{name: "fake-schema"}
|
|
514
|
+
registerFake(capEng)
|
|
515
|
+
|
|
516
|
+
schema := `{"type":"object","properties":{"score":{"type":"integer"}},"required":["score","issues"]}`
|
|
517
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-schema", "--json-schema", schema, "hi")
|
|
518
|
+
if err != nil {
|
|
519
|
+
t.Fatal(err)
|
|
520
|
+
}
|
|
521
|
+
if capEng.lastSchema == nil {
|
|
522
|
+
t.Fatal("lastSchema is nil")
|
|
523
|
+
}
|
|
524
|
+
if !strings.EqualFold(capEng.lastSchema.Type, "object") {
|
|
525
|
+
t.Errorf("Type = %q", capEng.lastSchema.Type)
|
|
526
|
+
}
|
|
527
|
+
if len(capEng.lastSchema.Required) != 2 ||
|
|
528
|
+
capEng.lastSchema.Required[0] != "score" || capEng.lastSchema.Required[1] != "issues" {
|
|
529
|
+
t.Errorf("Required = %v", capEng.lastSchema.Required)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --json-schema 非合法 JSON → usage 错误。
|
|
534
|
+
func TestJSONSchemaRejectsBadJSON(t *testing.T) {
|
|
535
|
+
_, _, err := runAskCmd(t, "", "-e", "claude", "--json-schema", "not json", "hi")
|
|
536
|
+
if _, ok := err.(*usageError); !ok {
|
|
537
|
+
t.Errorf("expected usageError, got %T: %v", err, err)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --json-schema type!=object → 拒绝。
|
|
542
|
+
func TestJSONSchemaRejectsNonObject(t *testing.T) {
|
|
543
|
+
_, _, err := runAskCmd(t, "", "-e", "claude", "--json-schema", `{"type":"array","items":{"type":"string"}}`, "hi")
|
|
544
|
+
if _, ok := err.(*usageError); !ok {
|
|
545
|
+
t.Errorf("expected usageError, got %T: %v", err, err)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// --max-tokens / --temperature 透传到 Request。
|
|
550
|
+
func TestModelOptionsPassThrough(t *testing.T) {
|
|
551
|
+
capEng := &capturingEngine{name: "fake-opts"}
|
|
552
|
+
registerFake(capEng)
|
|
553
|
+
|
|
554
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-opts", "--max-tokens", "32000", "--temperature", "0.3", "hi")
|
|
555
|
+
if err != nil {
|
|
556
|
+
t.Fatal(err)
|
|
557
|
+
}
|
|
558
|
+
if capEng.lastMaxTok != 32000 {
|
|
559
|
+
t.Errorf("MaxTokens = %d want 32000", capEng.lastMaxTok)
|
|
560
|
+
}
|
|
561
|
+
if capEng.lastTemp == nil || *capEng.lastTemp != 0.3 {
|
|
562
|
+
t.Errorf("Temperature = %v want 0.3", capEng.lastTemp)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
func TestAskNegativeRetries(t *testing.T) {
|
|
567
|
+
_, _, err := runAskCmd(t, "", "-e", "claude", "-r", "-1", "hi")
|
|
568
|
+
if err == nil {
|
|
569
|
+
t.Fatal("expected error")
|
|
570
|
+
}
|
|
571
|
+
if _, ok := err.(*usageError); !ok {
|
|
572
|
+
t.Errorf("error should be usageError, got %T", err)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
func TestAskEngineFailureJSONError(t *testing.T) {
|
|
577
|
+
registerFake(&stringEngine{name: "fake-bad", err: errors.New("connection refused")})
|
|
578
|
+
|
|
579
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-bad", "hi")
|
|
580
|
+
if err == nil {
|
|
581
|
+
t.Fatal("expected error")
|
|
582
|
+
}
|
|
583
|
+
if stdout != "" {
|
|
584
|
+
t.Errorf("stdout should be empty on failure, got %q", stdout)
|
|
585
|
+
}
|
|
586
|
+
if !strings.Contains(stderr, `"error"`) {
|
|
587
|
+
t.Errorf("stderr should contain json error envelope: %q", stderr)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── json 错误 envelope:error + reason 双字段 ────────────────
|
|
592
|
+
|
|
593
|
+
// 引擎失败时 json envelope 必须带 reason(根因),且 reason 不含外层包装前缀。
|
|
594
|
+
func TestErrorEnvelopeReasonField(t *testing.T) {
|
|
595
|
+
registerFake(&stringEngine{name: "fake-reason", err: errors.New("root cause xyz")})
|
|
596
|
+
|
|
597
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-reason", "hi")
|
|
598
|
+
if err == nil {
|
|
599
|
+
t.Fatal("expected error")
|
|
600
|
+
}
|
|
601
|
+
_ = stdout
|
|
602
|
+
if !strings.Contains(stderr, `"reason":"root cause xyz"`) {
|
|
603
|
+
t.Errorf("stderr should contain reason field with root cause: %q", stderr)
|
|
604
|
+
}
|
|
605
|
+
// error 字段保留完整链(含引擎名),reason 字段是纯根因。
|
|
606
|
+
if !strings.Contains(stderr, `"error":"`) {
|
|
607
|
+
t.Errorf("stderr should contain error field: %q", stderr)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// text 模式失败输出保持单行纯文本(不带 json)。
|
|
612
|
+
func TestErrorTextMode(t *testing.T) {
|
|
613
|
+
registerFake(&stringEngine{name: "fake-text-err", err: errors.New("boom text")})
|
|
614
|
+
|
|
615
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-text-err", "-o", "text", "hi")
|
|
616
|
+
if err == nil {
|
|
617
|
+
t.Fatal("expected error")
|
|
618
|
+
}
|
|
619
|
+
if stdout != "" {
|
|
620
|
+
t.Errorf("stdout should be empty, got %q", stdout)
|
|
621
|
+
}
|
|
622
|
+
if !strings.Contains(stderr, "boom text") || strings.Contains(stderr, `"error"`) {
|
|
623
|
+
t.Errorf("text mode stderr should be plain text line: %q", stderr)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
func TestExitCodeOf(t *testing.T) {
|
|
628
|
+
if exitCodeOf(&usageError{errors.New("x")}) != 2 {
|
|
629
|
+
t.Error("usageError should be exit 2")
|
|
630
|
+
}
|
|
631
|
+
if exitCodeOf(errors.New("x")) != 1 {
|
|
632
|
+
t.Error("other errors should be exit 1")
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// unregisterLast:agent 包未暴露反注册;fake 引擎名字带 fake- 前缀、
|
|
637
|
+
// 不影响真实引擎查找,残留无害。
|
|
638
|
+
func unregisterLast() {}
|
|
639
|
+
|
|
640
|
+
// ── --engines flag ───────────────────────────────────────────
|
|
641
|
+
//
|
|
642
|
+
// 注意:以下用例一律带 --no-models —— 模型探测会真的启动本机 CLI
|
|
643
|
+
//(trae-cli / llm / codex / openclaw / codebuddy),单元测试不该依赖它们。
|
|
644
|
+
// models 字段本身的行为由 TestEnginesFlagIncludesModels 用假引擎 + 假探测覆盖。
|
|
645
|
+
|
|
646
|
+
// --engines 默认即输出 JSON 数组(含引擎名与是否可用),无需 --json。
|
|
647
|
+
func TestEnginesFlagJSONByDefault(t *testing.T) {
|
|
648
|
+
registerFake(&stringEngine{name: "fake-list", text: "x"})
|
|
649
|
+
|
|
650
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
651
|
+
if err != nil {
|
|
652
|
+
t.Fatalf("--engines: %v", err)
|
|
653
|
+
}
|
|
654
|
+
if !strings.Contains(stdout, `"engine":"fake-list"`) || !strings.Contains(stdout, `"ok":true`) {
|
|
655
|
+
t.Errorf("stdout = %q, want json array with engine name and ok", stdout)
|
|
656
|
+
}
|
|
657
|
+
if strings.Contains(stdout, "ENGINE") {
|
|
658
|
+
t.Errorf("stdout = %q, want json output only (no text table)", stdout)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// --engines --json 旧调用形态仍然兼容(同样输出 JSON)。
|
|
663
|
+
func TestEnginesFlagJSON(t *testing.T) {
|
|
664
|
+
registerFake(&stringEngine{name: "fake-json-list", text: "x"})
|
|
665
|
+
|
|
666
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--json", "--no-models")
|
|
667
|
+
if err != nil {
|
|
668
|
+
t.Fatalf("--engines --json: %v", err)
|
|
669
|
+
}
|
|
670
|
+
if !strings.Contains(stdout, `"engine":"fake-json-list"`) || !strings.Contains(stdout, `"ok":true`) {
|
|
671
|
+
t.Errorf("stdout = %q, want json array with engine entries", stdout)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// --engines 时不应触发提问(无 prompt 也不报错)。
|
|
676
|
+
func TestEnginesFlagSkipsPrompt(t *testing.T) {
|
|
677
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
678
|
+
if err != nil {
|
|
679
|
+
t.Fatalf("--engines should not require prompt: %v", err)
|
|
680
|
+
}
|
|
681
|
+
if !strings.Contains(stdout, `"engine"`) {
|
|
682
|
+
t.Errorf("stdout = %q, want json engine list", stdout)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// modelListEngine 假引擎:在 stringEngine 之上实现 agent.ModelLister。
|
|
687
|
+
type modelListEngine struct {
|
|
688
|
+
stringEngine
|
|
689
|
+
models []string
|
|
690
|
+
err error
|
|
691
|
+
credits map[string]string // 非 nil 时实现 agent.ModelCreditLister
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
func (m *modelListEngine) ListModels(context.Context) ([]string, error) {
|
|
695
|
+
return m.models, m.err
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
func (m *modelListEngine) ModelCredits(context.Context) map[string]string {
|
|
699
|
+
return m.credits
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// stubModelProbe 把 CLI 层的模型探测限制在假引擎上:
|
|
703
|
+
// 假引擎(*modelListEngine)走它自己的 ListModels,其余引擎(真实 CLI 引擎)
|
|
704
|
+
// 直接返回"无动态来源"。t.Cleanup 自动还原。
|
|
705
|
+
func stubModelProbe(t *testing.T) {
|
|
706
|
+
t.Helper()
|
|
707
|
+
prev := probeEngineModels
|
|
708
|
+
probeEngineModels = func(ctx context.Context, l agent.ModelLister) ([]string, error) {
|
|
709
|
+
if _, ok := l.(*modelListEngine); ok {
|
|
710
|
+
return l.ListModels(ctx)
|
|
711
|
+
}
|
|
712
|
+
return nil, agent.ErrNoModelSource
|
|
713
|
+
}
|
|
714
|
+
t.Cleanup(func() { probeEngineModels = prev })
|
|
715
|
+
// 倍率探测与模型探测同链:只放行假引擎,防止真实引擎读真机缓存。
|
|
716
|
+
prevC := probeEngineCredits
|
|
717
|
+
probeEngineCredits = func(_ context.Context, l agent.ModelCreditLister) map[string]string {
|
|
718
|
+
if _, ok := l.(*modelListEngine); ok {
|
|
719
|
+
return l.ModelCredits(context.Background())
|
|
720
|
+
}
|
|
721
|
+
return nil
|
|
722
|
+
}
|
|
723
|
+
t.Cleanup(func() { probeEngineCredits = prevC })
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// enginesRow --engines 输出的一行(只看本用例关心的字段)。
|
|
727
|
+
type enginesRow struct {
|
|
728
|
+
Engine string `json:"engine"`
|
|
729
|
+
OK bool `json:"ok"`
|
|
730
|
+
Models []string `json:"models"`
|
|
731
|
+
ModelsNote string `json:"models_note"`
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// findEnginesRow 按引擎名取行。
|
|
735
|
+
func findEnginesRow(t *testing.T, rows []enginesRow, name string) enginesRow {
|
|
736
|
+
t.Helper()
|
|
737
|
+
for _, r := range rows {
|
|
738
|
+
if r.Engine == name {
|
|
739
|
+
return r
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
t.Fatalf("engine %q not found in %+v", name, rows)
|
|
743
|
+
return enginesRow{}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// --engines 每行带 models(动态探测结果);探测失败的引擎给 models_note。
|
|
747
|
+
func TestEnginesFlagIncludesModels(t *testing.T) {
|
|
748
|
+
okEngine := &modelListEngine{
|
|
749
|
+
stringEngine: stringEngine{name: "fake-models-ok"},
|
|
750
|
+
models: []string{"m-a", "m-b"},
|
|
751
|
+
}
|
|
752
|
+
errEngine := &modelListEngine{
|
|
753
|
+
stringEngine: stringEngine{name: "fake-models-err"},
|
|
754
|
+
err: errors.New("probe failed: boom"),
|
|
755
|
+
}
|
|
756
|
+
registerFake(okEngine)
|
|
757
|
+
registerFake(errEngine)
|
|
758
|
+
stubModelProbe(t)
|
|
759
|
+
|
|
760
|
+
stdout, _, err := runAskCmd(t, "", "--engines")
|
|
761
|
+
if err != nil {
|
|
762
|
+
t.Fatalf("--engines: %v", err)
|
|
763
|
+
}
|
|
764
|
+
var rows []enginesRow
|
|
765
|
+
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
|
|
766
|
+
t.Fatalf("unmarshal %q: %v", stdout, err)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
ok := findEnginesRow(t, rows, "fake-models-ok")
|
|
770
|
+
if len(ok.Models) != 2 || ok.Models[0] != "m-a" || ok.Models[1] != "m-b" {
|
|
771
|
+
t.Errorf("models = %v want [m-a m-b]", ok.Models)
|
|
772
|
+
}
|
|
773
|
+
if ok.ModelsNote != "" {
|
|
774
|
+
t.Errorf("models_note should be empty on success, got %q", ok.ModelsNote)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
bad := findEnginesRow(t, rows, "fake-models-err")
|
|
778
|
+
if len(bad.Models) != 0 {
|
|
779
|
+
t.Errorf("failed probe should not report models, got %v", bad.Models)
|
|
780
|
+
}
|
|
781
|
+
if !strings.Contains(bad.ModelsNote, "probe failed: boom") {
|
|
782
|
+
t.Errorf("models_note = %q, want probe error", bad.ModelsNote)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// --engines 对实现 ModelCreditLister 的引擎带 model_credits(积分倍率表);
|
|
787
|
+
// 未给倍率的引擎省略该字段。
|
|
788
|
+
func TestEnginesFlagIncludesModelCredits(t *testing.T) {
|
|
789
|
+
withCredits := &modelListEngine{
|
|
790
|
+
stringEngine: stringEngine{name: "fake-credits"},
|
|
791
|
+
models: []string{"fast-model", "deepseek-v4.1-flash", "custom-local:X"},
|
|
792
|
+
credits: map[string]string{"fast-model": "0.34", "deepseek-v4.1-flash": "0.00"},
|
|
793
|
+
}
|
|
794
|
+
withoutCredits := &modelListEngine{
|
|
795
|
+
stringEngine: stringEngine{name: "fake-no-credits"},
|
|
796
|
+
models: []string{"m-a"},
|
|
797
|
+
}
|
|
798
|
+
registerFake(withCredits)
|
|
799
|
+
registerFake(withoutCredits)
|
|
800
|
+
stubModelProbe(t)
|
|
801
|
+
|
|
802
|
+
stdout, _, err := runAskCmd(t, "", "--engines")
|
|
803
|
+
if err != nil {
|
|
804
|
+
t.Fatalf("--engines: %v", err)
|
|
805
|
+
}
|
|
806
|
+
var rows []struct {
|
|
807
|
+
Engine string `json:"engine"`
|
|
808
|
+
Models []string `json:"models"`
|
|
809
|
+
ModelCredits map[string]string `json:"model_credits"`
|
|
810
|
+
}
|
|
811
|
+
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
|
|
812
|
+
t.Fatalf("unmarshal %q: %v", stdout, err)
|
|
813
|
+
}
|
|
814
|
+
for _, r := range rows {
|
|
815
|
+
switch r.Engine {
|
|
816
|
+
case "fake-credits":
|
|
817
|
+
if !equalStrMap(r.ModelCredits, map[string]string{"fast-model": "0.34", "deepseek-v4.1-flash": "0.00"}) {
|
|
818
|
+
t.Errorf("model_credits = %v", r.ModelCredits)
|
|
819
|
+
}
|
|
820
|
+
case "fake-no-credits":
|
|
821
|
+
if r.ModelCredits != nil {
|
|
822
|
+
t.Errorf("engine without credits should omit model_credits, got %v", r.ModelCredits)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
func equalStrMap(a, b map[string]string) bool {
|
|
829
|
+
if len(a) != len(b) {
|
|
830
|
+
return false
|
|
831
|
+
}
|
|
832
|
+
for k, v := range a {
|
|
833
|
+
if b[k] != v {
|
|
834
|
+
return false
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return true
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --engines --no-models 完全不触发探测(不启动任何 CLI)。
|
|
841
|
+
func TestEnginesFlagNoModelsSkipsProbe(t *testing.T) {
|
|
842
|
+
registerFake(&modelListEngine{
|
|
843
|
+
stringEngine: stringEngine{name: "fake-no-models"},
|
|
844
|
+
models: []string{"m-a"},
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
prev := probeEngineModels
|
|
848
|
+
calls := 0
|
|
849
|
+
probeEngineModels = func(context.Context, agent.ModelLister) ([]string, error) {
|
|
850
|
+
calls++
|
|
851
|
+
return []string{"should-not-appear"}, nil
|
|
852
|
+
}
|
|
853
|
+
t.Cleanup(func() { probeEngineModels = prev })
|
|
854
|
+
|
|
855
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
856
|
+
if err != nil {
|
|
857
|
+
t.Fatalf("--engines --no-models: %v", err)
|
|
858
|
+
}
|
|
859
|
+
if calls != 0 {
|
|
860
|
+
t.Errorf("probe called %d times, want 0", calls)
|
|
861
|
+
}
|
|
862
|
+
if strings.Contains(stdout, `"models":`) || strings.Contains(stdout, `"models_note":`) {
|
|
863
|
+
t.Errorf("stdout = %q, want no models fields", stdout)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ── 子命令已删除 ──────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
// ask / engines / version / completion 子命令均已删除:
|
|
870
|
+
// 这些词不再被识别为子命令——作为位置参数它们就是普通 prompt。
|
|
871
|
+
// 真实执行会走引擎调用(测试环境无 codebuddy CLI,报 unknown engine),
|
|
872
|
+
// 这里用 cobra 命令树直接断言不存在任何子命令。
|
|
873
|
+
func TestSubcommandsRemoved(t *testing.T) {
|
|
874
|
+
cmd := NewRootCommand()
|
|
875
|
+
if subs := cmd.Commands(); len(subs) != 0 {
|
|
876
|
+
names := make([]string, 0, len(subs))
|
|
877
|
+
for _, s := range subs {
|
|
878
|
+
names = append(names, s.Name())
|
|
879
|
+
}
|
|
880
|
+
t.Errorf("root should have no subcommands, got %v", names)
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── --stream 流式 ─────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
// json 模式:NDJSON 事件流 + result 收尾行。
|
|
887
|
+
func TestStreamJSONMode(t *testing.T) {
|
|
888
|
+
registerFake(&streamingEngine{name: "fake-stream", text: "答案", thinking: "思考"})
|
|
889
|
+
|
|
890
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-stream", "--stream", "1+1")
|
|
891
|
+
if err != nil {
|
|
892
|
+
t.Fatalf("stream: %v", err)
|
|
893
|
+
}
|
|
894
|
+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
|
895
|
+
if len(lines) != 3 {
|
|
896
|
+
t.Fatalf("stdout lines = %d, want 3 (thinking + text + result): %q", len(lines), stdout)
|
|
897
|
+
}
|
|
898
|
+
if !strings.Contains(lines[0], `"type":"thinking"`) || !strings.Contains(lines[0], "思考") {
|
|
899
|
+
t.Errorf("line0 = %q, want thinking event", lines[0])
|
|
900
|
+
}
|
|
901
|
+
if !strings.Contains(lines[1], `"type":"text"`) || !strings.Contains(lines[1], "答案") {
|
|
902
|
+
t.Errorf("line1 = %q, want text event", lines[1])
|
|
903
|
+
}
|
|
904
|
+
if !strings.Contains(lines[2], `"type":"result"`) || !strings.Contains(lines[2], `"text":"答案"`) {
|
|
905
|
+
t.Errorf("line2 = %q, want result envelope", lines[2])
|
|
906
|
+
}
|
|
907
|
+
if !strings.Contains(lines[2], `"thinking":"思考"`) {
|
|
908
|
+
t.Errorf("result envelope missing thinking: %q", lines[2])
|
|
909
|
+
}
|
|
910
|
+
if stderr != "" {
|
|
911
|
+
t.Errorf("json 模式 stderr 应为空, got %q", stderr)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// text 模式:正文 → stdout,思考 → stderr。
|
|
916
|
+
func TestStreamTextMode(t *testing.T) {
|
|
917
|
+
registerFake(&streamingEngine{name: "fake-stream-t", text: "正文", thinking: "思考过程"})
|
|
918
|
+
|
|
919
|
+
stdout, stderr, err := runAskCmd(t, "", "-e", "fake-stream-t", "--stream", "-o", "text", "hi")
|
|
920
|
+
if err != nil {
|
|
921
|
+
t.Fatalf("stream text: %v", err)
|
|
922
|
+
}
|
|
923
|
+
if strings.TrimSpace(stdout) != "正文" {
|
|
924
|
+
t.Errorf("stdout = %q, want 正文", stdout)
|
|
925
|
+
}
|
|
926
|
+
if !strings.Contains(stderr, "思考过程") {
|
|
927
|
+
t.Errorf("stderr missing thinking: %q", stderr)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// --no-thinking:思考增量不转发。
|
|
932
|
+
func TestStreamNoThinking(t *testing.T) {
|
|
933
|
+
registerFake(&streamingEngine{name: "fake-stream-nt", text: "答案", thinking: "思考"})
|
|
934
|
+
|
|
935
|
+
// json 模式:无 thinking 事件行,result envelope 不含 thinking 字段
|
|
936
|
+
stdout, _, err := runAskCmd(t, "", "-e", "fake-stream-nt", "--stream", "--no-thinking", "hi")
|
|
937
|
+
if err != nil {
|
|
938
|
+
t.Fatalf("stream no-thinking: %v", err)
|
|
939
|
+
}
|
|
940
|
+
if strings.Contains(stdout, `"type":"thinking"`) {
|
|
941
|
+
t.Errorf("stdout should not contain thinking events: %q", stdout)
|
|
942
|
+
}
|
|
943
|
+
if strings.Contains(stdout, `"thinking":"思考"`) {
|
|
944
|
+
t.Errorf("result envelope should omit thinking: %q", stdout)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// text 模式:stderr 无思考
|
|
948
|
+
_, stderr, err := runAskCmd(t, "", "-e", "fake-stream-nt", "--stream", "--no-thinking", "-o", "text", "hi")
|
|
949
|
+
if err != nil {
|
|
950
|
+
t.Fatalf("stream no-thinking text: %v", err)
|
|
951
|
+
}
|
|
952
|
+
if strings.Contains(stderr, "思考") {
|
|
953
|
+
t.Errorf("stderr should not contain thinking: %q", stderr)
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// 不支持流式的引擎 → usage error(exit 2)。
|
|
958
|
+
func TestStreamUnsupportedEngine(t *testing.T) {
|
|
959
|
+
registerFake(&stringEngine{name: "fake-nostream", text: "x"})
|
|
960
|
+
|
|
961
|
+
_, _, err := runAskCmd(t, "", "-e", "fake-nostream", "--stream", "hi")
|
|
962
|
+
if err == nil {
|
|
963
|
+
t.Fatal("expected error for non-streaming engine")
|
|
964
|
+
}
|
|
965
|
+
if _, ok := err.(*usageError); !ok {
|
|
966
|
+
t.Errorf("error should be usageError (exit 2), got %T: %v", err, err)
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// 流式失败:json 模式 stdout 只含已发出的增量事件,无 result 行;
|
|
971
|
+
// 错误 envelope 不重复打印(reportedError)。
|
|
972
|
+
func TestStreamFailureJSON(t *testing.T) {
|
|
973
|
+
registerFake(&streamingEngine{name: "fake-stream-bad", err: errors.New("connection refused")})
|
|
974
|
+
|
|
975
|
+
stdout, _, err := runAskCmd(t, "", "-e", "fake-stream-bad", "--stream", "hi")
|
|
976
|
+
if err == nil {
|
|
977
|
+
t.Fatal("expected error")
|
|
978
|
+
}
|
|
979
|
+
if strings.Contains(stdout, `"type":"result"`) {
|
|
980
|
+
t.Errorf("failed stream should not emit result line: %q", stdout)
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 确保 cobra 命令满足接口。
|
|
985
|
+
var _ = cobra.Command{}
|
|
986
|
+
|
|
987
|
+
// ── 默认系统提示词(配置文件 systemPrompt)────────────────────
|
|
988
|
+
|
|
989
|
+
// writeCLIConfig 把配置内容落到临时文件并返回路径(供 t.Setenv(config.EnvPath, ...))。
|
|
990
|
+
func writeCLIConfig(t *testing.T, content string) string {
|
|
991
|
+
t.Helper()
|
|
992
|
+
p := filepath.Join(t.TempDir(), "config.json")
|
|
993
|
+
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
994
|
+
t.Fatal(err)
|
|
995
|
+
}
|
|
996
|
+
return p
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// 没给 -s → 用配置文件里的 systemPrompt(全局默认)。
|
|
1000
|
+
func TestSystemPromptDefaultsFromConfig(t *testing.T) {
|
|
1001
|
+
capEng := &capturingEngine{name: "fake-syscfg"}
|
|
1002
|
+
registerFake(capEng)
|
|
1003
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":"你是一个中文助手,始终用中文回答所有问题。"}`))
|
|
1004
|
+
|
|
1005
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-syscfg", "hi"); err != nil {
|
|
1006
|
+
t.Fatalf("ask: %v", err)
|
|
1007
|
+
}
|
|
1008
|
+
want := "你是一个中文助手,始终用中文回答所有问题。"
|
|
1009
|
+
if capEng.lastSystem != want {
|
|
1010
|
+
t.Errorf("SystemPrompt = %q want %q", capEng.lastSystem, want)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// -s 优先于配置里的默认值(给了就完全替换,不叠加)。
|
|
1015
|
+
func TestSystemFlagOverridesConfigDefault(t *testing.T) {
|
|
1016
|
+
capEng := &capturingEngine{name: "fake-sysover"}
|
|
1017
|
+
registerFake(capEng)
|
|
1018
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":"配置里的默认"}`))
|
|
1019
|
+
|
|
1020
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sysover", "-s", "命令行给的", "hi"); err != nil {
|
|
1021
|
+
t.Fatalf("ask: %v", err)
|
|
1022
|
+
}
|
|
1023
|
+
if capEng.lastSystem != "命令行给的" {
|
|
1024
|
+
t.Errorf("SystemPrompt = %q want 命令行值", capEng.lastSystem)
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// 配置里没有 systemPrompt(或文件不存在)→ 不注入,行为与以前一致。
|
|
1029
|
+
func TestSystemPromptAbsentNotInjected(t *testing.T) {
|
|
1030
|
+
capEng := &capturingEngine{name: "fake-sysnone"}
|
|
1031
|
+
registerFake(capEng)
|
|
1032
|
+
|
|
1033
|
+
// ① 配置文件存在但无该键
|
|
1034
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"arkclaw":{"url":"u","key":"k","claw_id":"c"}}`))
|
|
1035
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sysnone", "hi"); err != nil {
|
|
1036
|
+
t.Fatalf("ask: %v", err)
|
|
1037
|
+
}
|
|
1038
|
+
if capEng.lastSystem != "" {
|
|
1039
|
+
t.Errorf("无该键时应为空, got %q", capEng.lastSystem)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ② 配置文件不存在
|
|
1043
|
+
t.Setenv(config.EnvPath, filepath.Join(t.TempDir(), "absent.json"))
|
|
1044
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sysnone", "hi"); err != nil {
|
|
1045
|
+
t.Fatalf("ask: %v", err)
|
|
1046
|
+
}
|
|
1047
|
+
if capEng.lastSystem != "" {
|
|
1048
|
+
t.Errorf("文件缺失时应为空, got %q", capEng.lastSystem)
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// 环境变量 MAGIC_AGENT_SYSTEM_PROMPT 覆盖文件值。
|
|
1053
|
+
func TestSystemPromptEnvOverride(t *testing.T) {
|
|
1054
|
+
capEng := &capturingEngine{name: "fake-sysenv"}
|
|
1055
|
+
registerFake(capEng)
|
|
1056
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":"文件里的"}`))
|
|
1057
|
+
t.Setenv(config.EnvSystemPrompt, "环境变量里的")
|
|
1058
|
+
|
|
1059
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sysenv", "hi"); err != nil {
|
|
1060
|
+
t.Fatalf("ask: %v", err)
|
|
1061
|
+
}
|
|
1062
|
+
if capEng.lastSystem != "环境变量里的" {
|
|
1063
|
+
t.Errorf("SystemPrompt = %q want 环境变量值", capEng.lastSystem)
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// 配置文件语法错 → 不阻断调用(不注入默认值);-v 时在 stderr 说明原因。
|
|
1068
|
+
func TestBrokenConfigDoesNotBreakAsk(t *testing.T) {
|
|
1069
|
+
capEng := &capturingEngine{name: "fake-sysbad"}
|
|
1070
|
+
registerFake(capEng)
|
|
1071
|
+
t.Setenv(config.EnvPath, writeCLIConfig(t, `{"systemPrompt":`))
|
|
1072
|
+
|
|
1073
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-sysbad", "hi"); err != nil {
|
|
1074
|
+
t.Fatalf("坏配置不应让调用失败: %v", err)
|
|
1075
|
+
}
|
|
1076
|
+
if capEng.lastSystem != "" {
|
|
1077
|
+
t.Errorf("坏配置时不注入, got %q", capEng.lastSystem)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// -v:stderr 出现提示(不静默)
|
|
1081
|
+
_, stderr, err := runAskCmd(t, "", "-e", "fake-sysbad", "-v", "hi")
|
|
1082
|
+
if err != nil {
|
|
1083
|
+
t.Fatalf("ask -v: %v", err)
|
|
1084
|
+
}
|
|
1085
|
+
if !strings.Contains(stderr, "读取默认系统提示词失败") {
|
|
1086
|
+
t.Errorf("-v 应提示配置读取失败, stderr = %q", stderr)
|
|
1087
|
+
}
|
|
1088
|
+
}
|