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,759 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// dsh_test.go - DeepSeek Harness(dsh)引擎单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖两条通道:
|
|
6
|
+
//
|
|
7
|
+
// SDK(默认) dsh_sdk_test.go —— initialize 握手、session.event 解析(推理/正文/
|
|
8
|
+
// 工具调用/工具结果/turn 收尾)、模型路由解析、服务端拒绝与回退分流
|
|
9
|
+
// headless 本文件 —— 参数形态(--profile headless <prompt>)、stdout 取正文 /
|
|
10
|
+
// stderr 推理增量不混入、会话续接显式报错、JSONSchema 后处理、
|
|
11
|
+
// workspace 走子进程 cwd、无模型清单来源、settings.yaml 解析
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ 本文件里走 headless 的用例一律显式 `Profile: "headless"`(关掉 SDK 通道):
|
|
14
|
+
// 默认(Profile 空)会先试 SDK,假 CLI 说不了 JSON-RPC 就回退,会多起一次进程
|
|
15
|
+
// 并打一行回退告警,把「headless 行为」的断言搅浑。默认路径的行为由
|
|
16
|
+
// dsh_sdk_test.go 专门覆盖。
|
|
17
|
+
|
|
18
|
+
import (
|
|
19
|
+
"context"
|
|
20
|
+
"errors"
|
|
21
|
+
"fmt"
|
|
22
|
+
"os"
|
|
23
|
+
"path/filepath"
|
|
24
|
+
"strings"
|
|
25
|
+
"sync"
|
|
26
|
+
"testing"
|
|
27
|
+
"time"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
func TestDshLookup(t *testing.T) {
|
|
31
|
+
e := Lookup("dsh")
|
|
32
|
+
if e == nil {
|
|
33
|
+
t.Fatal(`Lookup("dsh") = nil`)
|
|
34
|
+
}
|
|
35
|
+
if e.Name() != "dsh" {
|
|
36
|
+
t.Errorf("Name() = %q want dsh", e.Name())
|
|
37
|
+
}
|
|
38
|
+
if Lookup("DSH") == nil {
|
|
39
|
+
t.Error(`Lookup("DSH") = nil, want case-insensitive match`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestDshIsEngine(t *testing.T) {
|
|
44
|
+
var e Engine = &DshEngine{}
|
|
45
|
+
if e.Name() != "dsh" {
|
|
46
|
+
t.Errorf("Name() = %q", e.Name())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// dsh 实现了 Streamer(默认 SDK 通道全流式,回退 headless 后流推理);支持常驻会话
|
|
51
|
+
// (SDK 通道下对同一会话继续 prompt),但不支持按 id 续接(-s / -c)。
|
|
52
|
+
func TestDshCapabilities(t *testing.T) {
|
|
53
|
+
if s := AsStreamer(&DshEngine{}); s == nil {
|
|
54
|
+
t.Error("dsh 应实现 Streamer(SDK 通道的 session.event / headless 的 stderr 推理增量)")
|
|
55
|
+
}
|
|
56
|
+
if !SupportsStream(&DshEngine{}) {
|
|
57
|
+
t.Error("SupportsStream(dsh) 应为 true")
|
|
58
|
+
}
|
|
59
|
+
if !AppendSupportOf("dsh") {
|
|
60
|
+
t.Error("dsh 应支持常驻会话(--keep-alive/--append):SDK 通道下同一会话可继续 prompt")
|
|
61
|
+
}
|
|
62
|
+
if AppendDefaultOn("dsh") {
|
|
63
|
+
t.Error("dsh 的常驻会话应默认关(需显式 --keep-alive),否则既有 --stream 调用会挂 5 分钟空闲窗口")
|
|
64
|
+
}
|
|
65
|
+
if got := WorkspaceSupportOf("dsh"); got != "cwd" {
|
|
66
|
+
t.Errorf("WorkspaceSupportOf(dsh) = %q want cwd(调用目录即 workspace 根)", got)
|
|
67
|
+
}
|
|
68
|
+
if got := AttachmentSupportOf("dsh"); got != "prompt" {
|
|
69
|
+
t.Errorf("AttachmentSupportOf(dsh) = %q want prompt", got)
|
|
70
|
+
}
|
|
71
|
+
if got := PermissionSupportOf("dsh"); got != "none" {
|
|
72
|
+
t.Errorf("PermissionSupportOf(dsh) = %q want none(预设无法表达四档)", got)
|
|
73
|
+
}
|
|
74
|
+
if got := AskSupportOf("dsh"); got != "none" {
|
|
75
|
+
t.Errorf("AskSupportOf(dsh) = %q want none", got)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestDshDetectWithBinPath(t *testing.T) {
|
|
80
|
+
dir := t.TempDir()
|
|
81
|
+
bin := filepath.Join(dir, "dsh")
|
|
82
|
+
if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
83
|
+
t.Fatal(err)
|
|
84
|
+
}
|
|
85
|
+
ok, note := (&DshEngine{BinPath: bin}).Detect()
|
|
86
|
+
if !ok {
|
|
87
|
+
t.Fatalf("Detect() = false, note=%q", note)
|
|
88
|
+
}
|
|
89
|
+
if note != bin {
|
|
90
|
+
t.Errorf("Detect() note = %q want %q", note, bin)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestDshDetectMissing(t *testing.T) {
|
|
95
|
+
// 显式给一个不存在的候选目录 + 清掉环境变量与 PATH,探测应失败。
|
|
96
|
+
t.Setenv("MAGIC_AGENT_DSH_BIN", "")
|
|
97
|
+
t.Setenv("PATH", t.TempDir())
|
|
98
|
+
ok, note := (&DshEngine{}).Detect()
|
|
99
|
+
if ok {
|
|
100
|
+
t.Skip("本机 PATH 上确实有 dsh,跳过「未安装」用例")
|
|
101
|
+
}
|
|
102
|
+
if !strings.Contains(note, "dsh") {
|
|
103
|
+
t.Errorf("note 应说明 dsh 缺失: %q", note)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// headless 参数形态:--profile headless <prompt>(启动器 flag 在前,任务文本在后)。
|
|
108
|
+
func TestDshBuildArgsHeadless(t *testing.T) {
|
|
109
|
+
dir := t.TempDir()
|
|
110
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo '最终正文'`)
|
|
111
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
112
|
+
resp, err := e.Complete(context.Background(), Request{
|
|
113
|
+
Messages: []Message{{Role: "user", Content: "跑一下测试"}},
|
|
114
|
+
})
|
|
115
|
+
if err != nil {
|
|
116
|
+
t.Fatalf("Complete: %v", err)
|
|
117
|
+
}
|
|
118
|
+
if resp.Text != "最终正文" {
|
|
119
|
+
t.Errorf("Text = %q want 最终正文", resp.Text)
|
|
120
|
+
}
|
|
121
|
+
if resp.Engine != "dsh" {
|
|
122
|
+
t.Errorf("Engine = %q want dsh", resp.Engine)
|
|
123
|
+
}
|
|
124
|
+
data, _ := os.ReadFile(log)
|
|
125
|
+
args := string(data)
|
|
126
|
+
if !strings.Contains(args, "--profile headless") {
|
|
127
|
+
t.Errorf("缺少 --profile headless: %q", args)
|
|
128
|
+
}
|
|
129
|
+
if !strings.Contains(args, "跑一下测试") {
|
|
130
|
+
t.Errorf("任务文本没作为位置参数传入: %q", args)
|
|
131
|
+
}
|
|
132
|
+
// 启动器 flag 必须排在任务文本之前(dsh 在第一个不认识的 token 处结束 flag 解析)。
|
|
133
|
+
if strings.Index(args, "--profile") > strings.Index(args, "跑一下测试") {
|
|
134
|
+
t.Errorf("--profile 应在任务文本之前: %q", args)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 自定义 profile 名:--profile 取引擎字段。
|
|
139
|
+
func TestDshBuildArgsCustomProfile(t *testing.T) {
|
|
140
|
+
dir := t.TempDir()
|
|
141
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
|
|
142
|
+
e := &DshEngine{BinPath: w.bin, Profile: "headless-cn"}
|
|
143
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
144
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
145
|
+
}); err != nil {
|
|
146
|
+
t.Fatalf("Complete: %v", err)
|
|
147
|
+
}
|
|
148
|
+
data, _ := os.ReadFile(log)
|
|
149
|
+
if !strings.Contains(string(data), "--profile headless-cn") {
|
|
150
|
+
t.Errorf("profile 未生效: %q", string(data))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// stdout 只取最终正文;stderr 的 "dsh: reasoning:" 推理增量不混进正文。
|
|
155
|
+
func TestDshReasoningOnStderrNotInText(t *testing.T) {
|
|
156
|
+
dir := t.TempDir()
|
|
157
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `echo 'dsh: reasoning: 先看看仓库' >&2
|
|
158
|
+
echo '正文:测试全过'`)
|
|
159
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
160
|
+
resp, err := e.Complete(context.Background(), Request{
|
|
161
|
+
Messages: []Message{{Role: "user", Content: "跑测试"}},
|
|
162
|
+
})
|
|
163
|
+
if err != nil {
|
|
164
|
+
t.Fatalf("Complete: %v", err)
|
|
165
|
+
}
|
|
166
|
+
if resp.Text != "正文:测试全过" {
|
|
167
|
+
t.Errorf("Text = %q(不应混入 stderr 的推理增量)", resp.Text)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 非零退出(turn/end 非 completed)→ 报错,且 stderr 末尾的终止原因进错误摘要。
|
|
172
|
+
func TestDshNonZeroExitReportsStderr(t *testing.T) {
|
|
173
|
+
dir := t.TempDir()
|
|
174
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `echo 'dsh: MISSING_CREDENTIAL: 未配置 API key' >&2
|
|
175
|
+
exit 1`)
|
|
176
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
177
|
+
_, err := e.Complete(context.Background(), Request{
|
|
178
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
179
|
+
})
|
|
180
|
+
if err == nil {
|
|
181
|
+
t.Fatal("非零退出应报错")
|
|
182
|
+
}
|
|
183
|
+
if !strings.Contains(err.Error(), "MISSING_CREDENTIAL") {
|
|
184
|
+
t.Errorf("错误里应带 stderr 摘要: %v", err)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 退出码 0 但没有任何正文 → 明确报错(不返回空 Text 让上层猜)。
|
|
189
|
+
func TestDshEmptyOutput(t *testing.T) {
|
|
190
|
+
dir := t.TempDir()
|
|
191
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `true`)
|
|
192
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
193
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
194
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
195
|
+
}); err == nil {
|
|
196
|
+
t.Fatal("空输出应报错")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 会话续接:headless 每次都是全新会话 → --session / -c 都明确报错(不静默开新会话)。
|
|
201
|
+
func TestDshSessionUnsupported(t *testing.T) {
|
|
202
|
+
dir := t.TempDir()
|
|
203
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo '不该被执行'`)
|
|
204
|
+
e := &DshEngine{BinPath: w.bin}
|
|
205
|
+
|
|
206
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
207
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
208
|
+
SessionID: "sess-1",
|
|
209
|
+
}); err == nil {
|
|
210
|
+
t.Error("--session 应报错")
|
|
211
|
+
} else if !strings.Contains(err.Error(), "不支持会话续接") {
|
|
212
|
+
t.Errorf("错误文案应说明不支持续接: %v", err)
|
|
213
|
+
}
|
|
214
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
215
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
216
|
+
Continue: true,
|
|
217
|
+
}); err == nil {
|
|
218
|
+
t.Error("-c/--continue 应报错")
|
|
219
|
+
}
|
|
220
|
+
// 两种情况下都不该真的启动 CLI。
|
|
221
|
+
if _, err := os.Stat(log); err == nil {
|
|
222
|
+
t.Error("续接被拒时不应启动 dsh 进程")
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 模型:headless 无 --model 参数 → 不传该 flag,并在 stderr 打一行告警(同 arkclaw)。
|
|
227
|
+
func TestDshModelNotForwarded(t *testing.T) {
|
|
228
|
+
dir := t.TempDir()
|
|
229
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
|
|
230
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
231
|
+
|
|
232
|
+
old := stderr
|
|
233
|
+
var buf strings.Builder
|
|
234
|
+
stderr = &buf
|
|
235
|
+
defer func() { stderr = old }()
|
|
236
|
+
|
|
237
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
238
|
+
Model: "deepseek-v4-pro",
|
|
239
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
240
|
+
}); err != nil {
|
|
241
|
+
t.Fatalf("Complete: %v", err)
|
|
242
|
+
}
|
|
243
|
+
data, _ := os.ReadFile(log)
|
|
244
|
+
if strings.Contains(string(data), "--model") {
|
|
245
|
+
t.Errorf("dsh headless 不应收到 --model: %q", string(data))
|
|
246
|
+
}
|
|
247
|
+
warn := buf.String()
|
|
248
|
+
if !strings.Contains(warn, "dsh") || !strings.Contains(warn, "deepseek-v4-pro") {
|
|
249
|
+
t.Errorf("告警文案未提及 dsh/模型名: %q", warn)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 未传 -m 时不打告警(不无脑刷屏)。
|
|
254
|
+
func TestDshNoModelNoWarning(t *testing.T) {
|
|
255
|
+
dir := t.TempDir()
|
|
256
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
|
|
257
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
258
|
+
|
|
259
|
+
old := stderr
|
|
260
|
+
var buf strings.Builder
|
|
261
|
+
stderr = &buf
|
|
262
|
+
defer func() { stderr = old }()
|
|
263
|
+
|
|
264
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
265
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
266
|
+
}); err != nil {
|
|
267
|
+
t.Fatalf("Complete: %v", err)
|
|
268
|
+
}
|
|
269
|
+
if buf.Len() != 0 {
|
|
270
|
+
t.Errorf("未传 -m 不该有告警: %q", buf.String())
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// JSONSchema:走与 llm/openclaw 相同的输出后处理(抽顶层必填字段的 JSON)。
|
|
275
|
+
func TestDshJSONSchemaPostProcess(t *testing.T) {
|
|
276
|
+
dir := t.TempDir()
|
|
277
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `echo '前置说明
|
|
278
|
+
{"title":"标题","score":9}
|
|
279
|
+
后置说明'`)
|
|
280
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
281
|
+
resp, err := e.Complete(context.Background(), Request{
|
|
282
|
+
Messages: []Message{{Role: "user", Content: "给我个 JSON"}},
|
|
283
|
+
JSONSchema: &JSONSchema{
|
|
284
|
+
Type: "object",
|
|
285
|
+
Properties: map[string]map[string]any{"title": {"type": "string"}, "score": {"type": "number"}},
|
|
286
|
+
Required: []string{"title", "score"},
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
if err != nil {
|
|
290
|
+
t.Fatalf("Complete: %v", err)
|
|
291
|
+
}
|
|
292
|
+
if !strings.HasPrefix(resp.Text, "{") || !strings.Contains(resp.Text, `"title":"标题"`) {
|
|
293
|
+
t.Errorf("Text 应为抽取后的紧凑 JSON: %q", resp.Text)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// workspace:走子进程 cwd(dsh 的「调用目录即 workspace 根」)。
|
|
298
|
+
func TestDshWorkspaceUsesCwd(t *testing.T) {
|
|
299
|
+
dir := t.TempDir()
|
|
300
|
+
log := filepath.Join(dir, "pwd.log")
|
|
301
|
+
bin := filepath.Join(dir, "dsh")
|
|
302
|
+
script := "#!/bin/sh\npwd > " + log + "\nprintf '%s\\n' \"$*\" >> " + log + "\necho 'ok'\n"
|
|
303
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
304
|
+
t.Fatal(err)
|
|
305
|
+
}
|
|
306
|
+
ws := t.TempDir()
|
|
307
|
+
e := &DshEngine{BinPath: bin, Profile: dshDefaultProfile}
|
|
308
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
309
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
310
|
+
Workspace: ws,
|
|
311
|
+
}); err != nil {
|
|
312
|
+
t.Fatalf("Complete: %v", err)
|
|
313
|
+
}
|
|
314
|
+
data, _ := os.ReadFile(log)
|
|
315
|
+
got := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)
|
|
316
|
+
// macOS 的 /var 与 /private/var 互为软链,按真实路径比较。
|
|
317
|
+
wantReal, _ := filepath.EvalSymlinks(ws)
|
|
318
|
+
gotReal, _ := filepath.EvalSymlinks(strings.TrimSpace(got[0]))
|
|
319
|
+
if gotReal != wantReal {
|
|
320
|
+
t.Errorf("子进程 cwd = %q want %q", gotReal, wantReal)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 系统提示词无独立通道 → 展平进 prompt 头部(与 trae/openclaw 同路)。
|
|
325
|
+
func TestDshSystemPromptFlattened(t *testing.T) {
|
|
326
|
+
dir := t.TempDir()
|
|
327
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
|
|
328
|
+
e := &DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}
|
|
329
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
330
|
+
SystemPrompt: "只输出中文",
|
|
331
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
332
|
+
}); err != nil {
|
|
333
|
+
t.Fatalf("Complete: %v", err)
|
|
334
|
+
}
|
|
335
|
+
data, _ := os.ReadFile(log)
|
|
336
|
+
if !strings.Contains(string(data), "只输出中文") {
|
|
337
|
+
t.Errorf("system prompt 应展平进任务文本: %q", string(data))
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// CLI 不存在:探测不到时 Complete 明确报错。
|
|
342
|
+
func TestDshNotFound(t *testing.T) {
|
|
343
|
+
t.Setenv("MAGIC_AGENT_DSH_BIN", "")
|
|
344
|
+
t.Setenv("PATH", t.TempDir())
|
|
345
|
+
if _, err := (&DshEngine{}).Complete(context.Background(), Request{
|
|
346
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
347
|
+
}); err == nil {
|
|
348
|
+
t.Skip("本机 PATH 上确实有 dsh,跳过「未安装」用例")
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 无模型清单来源:settings.yaml 不存在 → ErrNoModelSource + 原因。
|
|
353
|
+
// ⚠️ 必须把 DSH_HOME 指到临时目录,否则会读到开发机真实配置(测试不可复现)。
|
|
354
|
+
func TestDshListModelsNoSource(t *testing.T) {
|
|
355
|
+
t.Setenv("DSH_HOME", t.TempDir())
|
|
356
|
+
_, err := (&DshEngine{}).ListModels(context.Background())
|
|
357
|
+
if !errors.Is(err, ErrNoModelSource) {
|
|
358
|
+
t.Fatalf("want ErrNoModelSource, got %v", err)
|
|
359
|
+
}
|
|
360
|
+
if !strings.Contains(err.Error(), "settings.yaml") {
|
|
361
|
+
t.Errorf("原因应指向 settings.yaml: %v", err)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// settings.yaml 里没有任何 models 时同样是 ErrNoModelSource。
|
|
366
|
+
func TestDshListModelsEmptySettings(t *testing.T) {
|
|
367
|
+
home := t.TempDir()
|
|
368
|
+
t.Setenv("DSH_HOME", home)
|
|
369
|
+
if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte("llm-pi-ai:\n providers: {}\n"), 0o600); err != nil {
|
|
370
|
+
t.Fatal(err)
|
|
371
|
+
}
|
|
372
|
+
if _, err := (&DshEngine{}).ListModels(context.Background()); !errors.Is(err, ErrNoModelSource) {
|
|
373
|
+
t.Fatalf("want ErrNoModelSource, got %v", err)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 主路径:从 settings.yaml 读出 `provider/model` 列表。
|
|
378
|
+
func TestDshListModelsFromSettings(t *testing.T) {
|
|
379
|
+
home := t.TempDir()
|
|
380
|
+
t.Setenv("DSH_HOME", home)
|
|
381
|
+
cfg := `agent-default-model:
|
|
382
|
+
provider: modelverse
|
|
383
|
+
model: deepseek-v4.1-flash
|
|
384
|
+
|
|
385
|
+
llm-pi-ai:
|
|
386
|
+
providers:
|
|
387
|
+
modelverse:
|
|
388
|
+
api: openai-completions
|
|
389
|
+
baseURL: https://api.modelverse.cn/v1
|
|
390
|
+
apiKeyEnv: MODELVERSE_API_KEY
|
|
391
|
+
models:
|
|
392
|
+
- id: deepseek-v4.1-flash
|
|
393
|
+
name: DeepSeek V4.1 Flash
|
|
394
|
+
- id: glm-5.3
|
|
395
|
+
name: GLM-5.3
|
|
396
|
+
- id: kimi-k3
|
|
397
|
+
other-gw:
|
|
398
|
+
models:
|
|
399
|
+
- id: some-model
|
|
400
|
+
`
|
|
401
|
+
if err := os.WriteFile(filepath.Join(home, "settings.yaml"), []byte(cfg), 0o600); err != nil {
|
|
402
|
+
t.Fatal(err)
|
|
403
|
+
}
|
|
404
|
+
got, err := (&DshEngine{}).ListModels(context.Background())
|
|
405
|
+
if err != nil {
|
|
406
|
+
t.Fatalf("ListModels: %v", err)
|
|
407
|
+
}
|
|
408
|
+
want := []string{
|
|
409
|
+
"modelverse/deepseek-v4.1-flash",
|
|
410
|
+
"modelverse/glm-5.3",
|
|
411
|
+
"modelverse/kimi-k3",
|
|
412
|
+
"other-gw/some-model",
|
|
413
|
+
}
|
|
414
|
+
if !equalStrings(got, want) {
|
|
415
|
+
t.Errorf("ListModels() = %v\n want %v", got, want)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 解析器:块式 / 行内项 / 多 provider / 注释 / 去重 / 无 models 时回落默认模型。
|
|
420
|
+
func TestParseDshModelRoutes(t *testing.T) {
|
|
421
|
+
cases := []struct {
|
|
422
|
+
name string
|
|
423
|
+
yaml string
|
|
424
|
+
want []string
|
|
425
|
+
}{
|
|
426
|
+
{
|
|
427
|
+
name: "块式多模型",
|
|
428
|
+
yaml: "llm-pi-ai:\n providers:\n gw:\n models:\n - id: a\n - id: b\n",
|
|
429
|
+
want: []string{"gw/a", "gw/b"},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "条目带 name 子键",
|
|
433
|
+
yaml: "llm-pi-ai:\n providers:\n gw:\n models:\n - id: a\n name: A\n - id: b\n name: B\n",
|
|
434
|
+
want: []string{"gw/a", "gw/b"},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: "多 provider",
|
|
438
|
+
yaml: "llm-pi-ai:\n providers:\n gw1:\n models:\n - id: a\n gw2:\n models:\n - id: b\n",
|
|
439
|
+
want: []string{"gw1/a", "gw2/b"},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "行内项",
|
|
443
|
+
yaml: "llm-pi-ai:\n providers:\n gw:\n models:\n - {id: a}\n - {id: b, name: B}\n",
|
|
444
|
+
want: []string{"gw/a", "gw/b"},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "注释与空行不干扰",
|
|
448
|
+
yaml: "# 顶注\nllm-pi-ai:\n providers:\n gw:\n models: # 模型清单\n - id: a # 甲\n\n - id: b\n",
|
|
449
|
+
want: []string{"gw/a", "gw/b"},
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: "去重",
|
|
453
|
+
yaml: "llm-pi-ai:\n providers:\n gw:\n models:\n - id: a\n - id: a\n",
|
|
454
|
+
want: []string{"gw/a"},
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: "models 为空则回落 agent-default-model",
|
|
458
|
+
yaml: "agent-default-model:\n provider: gw\n model: only-one\n",
|
|
459
|
+
want: []string{"gw/only-one"},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "行内 agent-default-model",
|
|
463
|
+
yaml: "agent-default-model: {provider: gw, model: m1}\n",
|
|
464
|
+
want: []string{"gw/m1"},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: "结构不符 → 空",
|
|
468
|
+
yaml: "providers:\n gw:\n models:\n - id: a\n",
|
|
469
|
+
want: nil,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "空文件 → 空",
|
|
473
|
+
yaml: "",
|
|
474
|
+
want: nil,
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "其他段的 models 不误命中",
|
|
478
|
+
yaml: "some-other-plugin:\n providers:\n gw:\n models:\n - id: nope\n",
|
|
479
|
+
want: nil,
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
for _, c := range cases {
|
|
483
|
+
got := parseDshModelRoutes(c.yaml)
|
|
484
|
+
if len(got) == 0 && len(c.want) == 0 {
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if !equalStrings(got, c.want) {
|
|
488
|
+
t.Errorf("%s: got %v want %v", c.name, got, c.want)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 默认模型解析:块式 / 行内式 / 引号 / 缺段 / 段后换键。
|
|
494
|
+
func TestParseDshDefaultModel(t *testing.T) {
|
|
495
|
+
cases := []struct {
|
|
496
|
+
name string
|
|
497
|
+
yaml string
|
|
498
|
+
want string
|
|
499
|
+
}{
|
|
500
|
+
{
|
|
501
|
+
name: "块式",
|
|
502
|
+
yaml: "llm-deepseek:\n reasoningEffort: max\nagent-default-model:\n provider: deepseek\n model: deepseek-chat\n",
|
|
503
|
+
want: "deepseek-chat",
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "行内式",
|
|
507
|
+
yaml: "agent-default-model: {provider: deepseek, model: deepseek-v4-pro}\n",
|
|
508
|
+
want: "deepseek-v4-pro",
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: "带引号",
|
|
512
|
+
yaml: "agent-default-model:\n model: \"deepseek-chat\"\n",
|
|
513
|
+
want: "deepseek-chat",
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: "缺段",
|
|
517
|
+
yaml: "llm-deepseek:\n reasoningEffort: max\n",
|
|
518
|
+
want: "",
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: "空文件",
|
|
522
|
+
yaml: "",
|
|
523
|
+
want: "",
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: "注释不误命中",
|
|
527
|
+
yaml: "# agent-default-model:\n# model: nope\n",
|
|
528
|
+
want: "",
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
for _, c := range cases {
|
|
532
|
+
if got := parseDshDefaultModel(c.yaml); got != c.want {
|
|
533
|
+
t.Errorf("%s: got %q want %q", c.name, got, c.want)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// DshConfiguredModel:从 $DSH_HOME/settings.yaml 读默认模型;读不到返回空串(不报错)。
|
|
539
|
+
func TestDshConfiguredModel(t *testing.T) {
|
|
540
|
+
home := t.TempDir()
|
|
541
|
+
t.Setenv("DSH_HOME", home)
|
|
542
|
+
if got := DshConfiguredModel(); got != "" {
|
|
543
|
+
t.Errorf("settings.yaml 不存在时应返回空串, got %q", got)
|
|
544
|
+
}
|
|
545
|
+
if err := os.WriteFile(filepath.Join(home, "settings.yaml"),
|
|
546
|
+
[]byte("agent-default-model:\n provider: deepseek\n model: deepseek-chat\n"), 0o600); err != nil {
|
|
547
|
+
t.Fatal(err)
|
|
548
|
+
}
|
|
549
|
+
if got := DshConfiguredModel(); got != "deepseek-chat" {
|
|
550
|
+
t.Errorf("DshConfiguredModel() = %q want deepseek-chat", got)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Response.Model:配置里读不到时回落 "config-default"(标签,不是模型名)。
|
|
555
|
+
func TestDshResponseModelFallback(t *testing.T) {
|
|
556
|
+
t.Setenv("DSH_HOME", t.TempDir())
|
|
557
|
+
dir := t.TempDir()
|
|
558
|
+
w, _ := argsCaptureCLI(t, dir, "dsh", `echo 'ok'`)
|
|
559
|
+
resp, err := (&DshEngine{BinPath: w.bin, Profile: dshDefaultProfile}).Complete(context.Background(), Request{
|
|
560
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
561
|
+
})
|
|
562
|
+
if err != nil {
|
|
563
|
+
t.Fatalf("Complete: %v", err)
|
|
564
|
+
}
|
|
565
|
+
if resp.Model != "config-default" {
|
|
566
|
+
t.Errorf("Model = %q want config-default", resp.Model)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// DshHome:$DSH_HOME 优先,其次 ~/.dsh。
|
|
571
|
+
func TestDshHome(t *testing.T) {
|
|
572
|
+
t.Setenv("DSH_HOME", "/tmp/dsh-home-x")
|
|
573
|
+
if got := DshHome(); got != "/tmp/dsh-home-x" {
|
|
574
|
+
t.Errorf("DshHome() = %q want /tmp/dsh-home-x", got)
|
|
575
|
+
}
|
|
576
|
+
t.Setenv("DSH_HOME", "")
|
|
577
|
+
got := DshHome()
|
|
578
|
+
if got == "" || !strings.HasSuffix(got, ".dsh") {
|
|
579
|
+
t.Errorf("DshHome() = %q want 以 .dsh 结尾", got)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 默认超时要盖住「dsh 自带工具循环」的长任务(不短于 5 分钟)。
|
|
584
|
+
func TestDefaultDshTimeoutCoversAgentTasks(t *testing.T) {
|
|
585
|
+
if DefaultDshTimeout < 5*time.Minute {
|
|
586
|
+
t.Errorf("DefaultDshTimeout = %v,agent 任务常常分钟级,应留足余量", DefaultDshTimeout)
|
|
587
|
+
}
|
|
588
|
+
// Runner 按引擎类型取默认超时。
|
|
589
|
+
r := &Runner{Engine: &DshEngine{}}
|
|
590
|
+
if got := r.defaultTimeout(); got != DefaultDshTimeout {
|
|
591
|
+
t.Errorf("Runner.defaultTimeout() = %v want %v", got, DefaultDshTimeout)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── 流式(--stream)────────────────────────────────────────────
|
|
596
|
+
//
|
|
597
|
+
// dsh 的增量在 stderr("dsh: reasoning:" 起头),正文一次性走 stdout。
|
|
598
|
+
// 实测依据:2026-09-21,dsh 0.1.5-rc.2(见 dsh.go 的 Stream 注释)。
|
|
599
|
+
|
|
600
|
+
// dshStreamStub 造一个假 dsh:推理分批写 stderr,正文最后写 stdout。
|
|
601
|
+
func dshStreamStub(t *testing.T, dir string) (bin string) {
|
|
602
|
+
t.Helper()
|
|
603
|
+
bin = filepath.Join(dir, "dsh")
|
|
604
|
+
script := `#!/bin/sh
|
|
605
|
+
echo "dsh: reasoning:" >&2
|
|
606
|
+
echo "第一步:读题" >&2
|
|
607
|
+
sleep 0.05
|
|
608
|
+
echo "第二步:算 17*20=340" >&2
|
|
609
|
+
sleep 0.05
|
|
610
|
+
echo "第三步:算 17*3=51" >&2
|
|
611
|
+
echo "结果是 391"
|
|
612
|
+
`
|
|
613
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
614
|
+
t.Fatal(err)
|
|
615
|
+
}
|
|
616
|
+
return bin
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 推理增量逐条 KindThinking;正文收尾一条 KindText。
|
|
620
|
+
func TestDshStreamThinkingAndFinalText(t *testing.T) {
|
|
621
|
+
dir := t.TempDir()
|
|
622
|
+
bin := dshStreamStub(t, dir)
|
|
623
|
+
e := &DshEngine{BinPath: bin, Profile: dshDefaultProfile}
|
|
624
|
+
|
|
625
|
+
var mu sync.Mutex
|
|
626
|
+
var events []StreamEvent
|
|
627
|
+
res, err := e.Stream(context.Background(), Request{
|
|
628
|
+
Messages: []Message{{Role: "user", Content: "算 17*23"}},
|
|
629
|
+
}, func(ev StreamEvent) {
|
|
630
|
+
mu.Lock()
|
|
631
|
+
events = append(events, ev)
|
|
632
|
+
mu.Unlock()
|
|
633
|
+
})
|
|
634
|
+
if err != nil {
|
|
635
|
+
t.Fatalf("Stream: %v", err)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 正文:一次性给出,等于 stdout 去空白。
|
|
639
|
+
if res.Text != "结果是 391" {
|
|
640
|
+
t.Errorf("Text = %q want 结果是 391", res.Text)
|
|
641
|
+
}
|
|
642
|
+
// 思考过程:stderr 推理段拼接(标题行不算内容)。
|
|
643
|
+
if !strings.Contains(res.Thinking, "第一步:读题") || !strings.Contains(res.Thinking, "第三步:算 17*3=51") {
|
|
644
|
+
t.Errorf("Thinking 不完整: %q", res.Thinking)
|
|
645
|
+
}
|
|
646
|
+
if strings.Contains(res.Thinking, "dsh: reasoning:") {
|
|
647
|
+
t.Errorf("Thinking 不该包含标题行: %q", res.Thinking)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 事件:3 条 thinking + 1 条 text + 1 条 turn_end;thinking 全在 text 之前,
|
|
651
|
+
// 且 text 与 turn_end 是最后两条(正文一次性收尾 → 立刻报「本轮做完」)。
|
|
652
|
+
mu.Lock()
|
|
653
|
+
defer mu.Unlock()
|
|
654
|
+
var thinkings, texts int
|
|
655
|
+
firstTextIdx := -1
|
|
656
|
+
for i, ev := range events {
|
|
657
|
+
switch ev.Kind {
|
|
658
|
+
case KindThinking:
|
|
659
|
+
thinkings++
|
|
660
|
+
case KindText:
|
|
661
|
+
texts++
|
|
662
|
+
if firstTextIdx < 0 {
|
|
663
|
+
firstTextIdx = i
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if thinkings != 3 {
|
|
668
|
+
t.Errorf("KindThinking 事件数 = %d want 3(%v)", thinkings, events)
|
|
669
|
+
}
|
|
670
|
+
if texts != 1 {
|
|
671
|
+
t.Errorf("KindText 事件数 = %d want 1(%v)", texts, events)
|
|
672
|
+
}
|
|
673
|
+
if firstTextIdx != len(events)-2 {
|
|
674
|
+
t.Errorf("KindText 应是倒数第二个事件(正文一次性收尾后补 turn_end),实际 idx=%d/%d",
|
|
675
|
+
firstTextIdx, len(events)-1)
|
|
676
|
+
}
|
|
677
|
+
if last := events[len(events)-1]; last.Kind != KindTurnEnd {
|
|
678
|
+
t.Errorf("最后一个事件应是 KindTurnEnd,实际 %v", last)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 非推理行(启动日志等)不进 Thinking,也不当增量发出。
|
|
683
|
+
func TestDshStreamIgnoresNonReasoningStderr(t *testing.T) {
|
|
684
|
+
dir := t.TempDir()
|
|
685
|
+
bin := filepath.Join(dir, "dsh")
|
|
686
|
+
script := `#!/bin/sh
|
|
687
|
+
echo "openviking: 插件已注册" >&2
|
|
688
|
+
echo "dsh: reasoning:" >&2
|
|
689
|
+
echo "真正的推理" >&2
|
|
690
|
+
echo "正文"
|
|
691
|
+
`
|
|
692
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
693
|
+
t.Fatal(err)
|
|
694
|
+
}
|
|
695
|
+
var events []StreamEvent
|
|
696
|
+
res, err := (&DshEngine{BinPath: bin, Profile: dshDefaultProfile}).Stream(context.Background(), Request{
|
|
697
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
698
|
+
}, func(ev StreamEvent) { events = append(events, ev) })
|
|
699
|
+
if err != nil {
|
|
700
|
+
t.Fatalf("Stream: %v", err)
|
|
701
|
+
}
|
|
702
|
+
if res.Thinking != "真正的推理\n" {
|
|
703
|
+
t.Errorf("Thinking = %q want %q", res.Thinking, "真正的推理\n")
|
|
704
|
+
}
|
|
705
|
+
for _, ev := range events {
|
|
706
|
+
if ev.Kind == KindThinking && strings.Contains(ev.Text, "openviking") {
|
|
707
|
+
t.Errorf("启动日志被当成推理增量: %v", ev)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 流式路径同样拒续接(与 Complete 同一契约),且不启动进程。
|
|
713
|
+
func TestDshStreamRejectsSession(t *testing.T) {
|
|
714
|
+
dir := t.TempDir()
|
|
715
|
+
w, log := argsCaptureCLI(t, dir, "dsh", `echo '不该被执行'`)
|
|
716
|
+
_, err := (&DshEngine{BinPath: w.bin}).Stream(context.Background(), Request{
|
|
717
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
718
|
+
SessionID: "s1",
|
|
719
|
+
}, nil)
|
|
720
|
+
if err == nil || !strings.Contains(err.Error(), "不支持会话续接") {
|
|
721
|
+
t.Fatalf("流式路径也应拒续接, got %v", err)
|
|
722
|
+
}
|
|
723
|
+
if _, serr := os.Stat(log); serr == nil {
|
|
724
|
+
t.Error("拒续接时不应启动 dsh 进程")
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 流式路径的非零退出:错误带 stderr 摘要。
|
|
729
|
+
func TestDshStreamNonZeroExit(t *testing.T) {
|
|
730
|
+
dir := t.TempDir()
|
|
731
|
+
bin := filepath.Join(dir, "dsh")
|
|
732
|
+
script := "#!/bin/sh\necho 'dsh: MISSING_CREDENTIAL: 未配置' >&2\nexit 1\n"
|
|
733
|
+
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
734
|
+
t.Fatal(err)
|
|
735
|
+
}
|
|
736
|
+
_, err := (&DshEngine{BinPath: bin, Profile: dshDefaultProfile}).Stream(context.Background(), Request{
|
|
737
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
738
|
+
}, nil)
|
|
739
|
+
if err == nil || !strings.Contains(err.Error(), "MISSING_CREDENTIAL") {
|
|
740
|
+
t.Fatalf("应带 stderr 摘要, got %v", err)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 参数矩阵:MaxTokens / Temperature / Tools / Timeout 在 dsh 上不产生任何 flag。
|
|
745
|
+
func TestDshIgnoredParamsProduceNoFlags(t *testing.T) {
|
|
746
|
+
t.Setenv(DshProfileEnv, "") // 隔离外部 profile 覆盖
|
|
747
|
+
e := &DshEngine{}
|
|
748
|
+
temp := 0.7
|
|
749
|
+
args := e.buildArgs(Request{
|
|
750
|
+
MaxTokens: 4096,
|
|
751
|
+
Temperature: &temp,
|
|
752
|
+
Tools: ToolsOn,
|
|
753
|
+
Timeout: 3 * time.Minute,
|
|
754
|
+
}, "【用户】\nhi")
|
|
755
|
+
want := []string{"--profile", "headless", "【用户】\nhi"}
|
|
756
|
+
if fmt.Sprint(args) != fmt.Sprint(want) {
|
|
757
|
+
t.Errorf("buildArgs = %v want %v", args, want)
|
|
758
|
+
}
|
|
759
|
+
}
|