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,587 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// ask_test.go - 「需要用户选择」统一映射层的单测。
|
|
4
|
+
//
|
|
5
|
+
// 用例里的 payload 分两类,注释里都标了出处:
|
|
6
|
+
// 真机抓包 —— 2026-09-18 用 claude 2.1.146 / 本机 WorkBuddy codebuddy 实跑抓到
|
|
7
|
+
// (`printf … | claude -p --input-format stream-json --output-format stream-json --verbose`);
|
|
8
|
+
// 文档形状 —— 官方 SDK 文档给的 control_request / updatedInput 例子。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"encoding/json"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------- 测试素材
|
|
17
|
+
|
|
18
|
+
// askToolUseLineReal 真机抓包(claude 2.1.146):模型调用 AskUserQuestion 的 assistant 行。
|
|
19
|
+
// 只保留本层关心的字段(type / message.content),其余(model / usage / uuid…)略。
|
|
20
|
+
const askToolUseLineReal = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call_01a0b32ddcb27515aa596902","name":"AskUserQuestion","input":{"questions":[{"header":"午餐选择","multiSelect":false,"options":[{"description":"选拉面","label":"拉面"},{"description":"选盖饭","label":"盖饭"}],"question":"午餐吃拉面还是盖饭?"}]}}]},"session_id":"ad0f2b93-7916-4d8a-8852-9756c179aff1"}`
|
|
21
|
+
|
|
22
|
+
// askAutoDenyLineReal 真机抓包:CLI 紧接着自己回的那条**自动拒绝**(模型拿不到用户答案)。
|
|
23
|
+
const askAutoDenyLineReal = `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Answer questions?","is_error":true,"tool_use_id":"call_01a0b32ddcb27515aa596902"}]},"parent_tool_use_id":null,"session_id":"ad0f2b93-7916-4d8a-8852-9756c179aff1","tool_use_result":"Error: Answer questions?"}`
|
|
24
|
+
|
|
25
|
+
// askControlRequestDoc 文档形状:SDK 层 can_use_tool 请求(宿主自己实现 canUseTool 时才有)。
|
|
26
|
+
const askControlRequestDoc = `{"type":"control_request","request_id":"perm-1","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","tool_use_id":"toolu_01","input":{"questions":[{"question":"用哪个数据库?","header":"数据库","multiSelect":false,"options":[{"label":"PostgreSQL","description":"关系型数据库"},{"label":"MongoDB","description":"文档数据库"}]}]},"permission_suggestions":[]}}`
|
|
27
|
+
|
|
28
|
+
// askPermissionControlRequestDoc 文档形状:非提问类的待授权请求(工具不是 AskUserQuestion)。
|
|
29
|
+
const askPermissionControlRequestDoc = `{"type":"control_request","request_id":"perm-2","request":{"subtype":"can_use_tool","tool_name":"Bash","tool_use_id":"toolu_02","input":{"command":"npm test"}}}`
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------- 解析
|
|
32
|
+
|
|
33
|
+
func TestParseAskLineRealToolUse(t *testing.T) {
|
|
34
|
+
req, ok := ParseAskLine("claude", askToolUseLineReal)
|
|
35
|
+
if !ok {
|
|
36
|
+
t.Fatal("真机抓包的 AskUserQuestion tool_use 行没被识别")
|
|
37
|
+
}
|
|
38
|
+
if req.Kind != AskKindQuestion || req.Source != AskSourceToolUse {
|
|
39
|
+
t.Errorf("Kind/Source = %q/%q,期望 question/tool_use", req.Kind, req.Source)
|
|
40
|
+
}
|
|
41
|
+
if req.Engine != "claude" {
|
|
42
|
+
t.Errorf("Engine = %q,期望 claude", req.Engine)
|
|
43
|
+
}
|
|
44
|
+
if req.ToolUseID != "call_01a0b32ddcb27515aa596902" {
|
|
45
|
+
t.Errorf("ToolUseID = %q", req.ToolUseID)
|
|
46
|
+
}
|
|
47
|
+
if len(req.Questions) != 1 {
|
|
48
|
+
t.Fatalf("问题数 = %d,期望 1", len(req.Questions))
|
|
49
|
+
}
|
|
50
|
+
q := req.Questions[0]
|
|
51
|
+
if q.ID != "q0" {
|
|
52
|
+
t.Errorf("归一化 id = %q,期望 q0", q.ID)
|
|
53
|
+
}
|
|
54
|
+
if q.Text != "午餐吃拉面还是盖饭?" || q.Header != "午餐选择" || q.MultiSelect {
|
|
55
|
+
t.Errorf("问题字段不对: %+v", q)
|
|
56
|
+
}
|
|
57
|
+
if len(q.Options) != 2 || q.Options[0].Label != "拉面" || q.Options[0].Description != "选拉面" || q.Options[1].Label != "盖饭" {
|
|
58
|
+
t.Errorf("选项不对: %+v", q.Options)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestParseAskLineControlRequest(t *testing.T) {
|
|
63
|
+
req, ok := ParseAskLine("claude", askControlRequestDoc)
|
|
64
|
+
if !ok {
|
|
65
|
+
t.Fatal("control_request(can_use_tool) 没被识别")
|
|
66
|
+
}
|
|
67
|
+
if req.Source != AskSourceControlRequest || req.RequestID != "perm-1" || req.ToolUseID != "toolu_01" {
|
|
68
|
+
t.Errorf("来源/request_id/tool_use_id 不对: %+v", req)
|
|
69
|
+
}
|
|
70
|
+
if len(req.Questions) != 1 || req.Questions[0].Text != "用哪个数据库?" {
|
|
71
|
+
t.Errorf("问题没解析出来: %+v", req.Questions)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestParseAskLinePermissionKind(t *testing.T) {
|
|
76
|
+
req, ok := ParseAskLine("codebuddy", askPermissionControlRequestDoc)
|
|
77
|
+
if !ok {
|
|
78
|
+
t.Fatal("待授权的 control_request 没被识别")
|
|
79
|
+
}
|
|
80
|
+
if req.Kind != AskKindPermission {
|
|
81
|
+
t.Errorf("Kind = %q,期望 permission", req.Kind)
|
|
82
|
+
}
|
|
83
|
+
if len(req.Questions) != 0 {
|
|
84
|
+
t.Errorf("待授权请求不该有问题列表: %+v", req.Questions)
|
|
85
|
+
}
|
|
86
|
+
if !strings.Contains(string(req.ToolInput), "npm test") {
|
|
87
|
+
t.Errorf("工具入参没保留: %s", req.ToolInput)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestParseAskLineNotAsk(t *testing.T) {
|
|
92
|
+
cases := []struct {
|
|
93
|
+
name string
|
|
94
|
+
line string
|
|
95
|
+
}{
|
|
96
|
+
{"CLI 的自动拒绝 tool_result", askAutoDenyLineReal},
|
|
97
|
+
{"普通正文", `{"type":"assistant","message":{"content":[{"type":"text","text":"你好"}]}}`},
|
|
98
|
+
{"别的工具的 tool_use", `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"Bash","input":{"command":"ls"}}]}}`},
|
|
99
|
+
{"AskUserQuestion 但问题为空", `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"AskUserQuestion","input":{"questions":[]}}]}}`},
|
|
100
|
+
{"control_request 但子类型不是 can_use_tool", `{"type":"control_request","request_id":"r1","request":{"subtype":"rewind_files","tool_name":"AskUserQuestion"}}`},
|
|
101
|
+
{"result 收尾行", `{"type":"result","subtype":"success","result":"done"}`},
|
|
102
|
+
{"非 JSON 杂讯", `not json at all`},
|
|
103
|
+
{"空行", ``},
|
|
104
|
+
}
|
|
105
|
+
for _, c := range cases {
|
|
106
|
+
t.Run(c.name, func(t *testing.T) {
|
|
107
|
+
if req, ok := ParseAskLine("claude", c.line); ok {
|
|
108
|
+
t.Errorf("不该识别成「需要用户选择」,却得到 %+v", req)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func TestNormalizeAskToolUse(t *testing.T) {
|
|
115
|
+
// 工具名容忍大小写与下划线差异;参数是流式累积出来的完整 JSON。
|
|
116
|
+
input := json.RawMessage(`{"questions":[{"question":"Q","header":"H","multiSelect":true,"options":[{"label":"A"}]}]}`)
|
|
117
|
+
req, ok := NormalizeAskToolUse("claude", "ask_user_question", input, "call_1")
|
|
118
|
+
if !ok {
|
|
119
|
+
t.Fatal("下划线写法的工具名没被认出来")
|
|
120
|
+
}
|
|
121
|
+
if req.Questions[0].MultiSelect != true {
|
|
122
|
+
t.Errorf("multiSelect 没解析: %+v", req.Questions[0])
|
|
123
|
+
}
|
|
124
|
+
if _, ok := NormalizeAskToolUse("claude", "Bash", input, "call_1"); ok {
|
|
125
|
+
t.Error("Bash 不该被识别成提问")
|
|
126
|
+
}
|
|
127
|
+
if _, ok := NormalizeAskToolUse("claude", "AskUserQuestion", nil, "call_1"); ok {
|
|
128
|
+
t.Error("空入参不该被识别成提问")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------- 渲染
|
|
133
|
+
|
|
134
|
+
// q0Req 造一个两问(单选 + 多选)的统一请求,供渲染用例复用。
|
|
135
|
+
func q0Req(t *testing.T) *AskRequest {
|
|
136
|
+
t.Helper()
|
|
137
|
+
req, ok := ParseAskLine("claude", `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"call_9","name":"AskUserQuestion","input":{"questions":[
|
|
138
|
+
{"question":"用什么格式?","header":"Format","multiSelect":false,"options":[{"label":"摘要","description":"简短"},{"label":"详细","description":"展开"}]},
|
|
139
|
+
{"question":"包含哪些章节?","header":"Sections","multiSelect":true,"options":[{"label":"引言","description":"开头"},{"label":"结论","description":"收尾"}]}
|
|
140
|
+
]}}]}}`)
|
|
141
|
+
if !ok {
|
|
142
|
+
t.Fatal("素材没解析出来")
|
|
143
|
+
}
|
|
144
|
+
return req
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestEncodeAskAnswerAllow(t *testing.T) {
|
|
148
|
+
req := q0Req(t)
|
|
149
|
+
out, err := EncodeAskAnswer("claude", req, AskAnswer{Choices: []AskChoice{
|
|
150
|
+
{QuestionID: "q0", Labels: []string{"摘要"}},
|
|
151
|
+
{Question: "包含哪些章节?", Labels: []string{"引言", "结论"}},
|
|
152
|
+
}})
|
|
153
|
+
if err != nil {
|
|
154
|
+
t.Fatalf("渲染失败: %v", err)
|
|
155
|
+
}
|
|
156
|
+
var got struct {
|
|
157
|
+
Behavior string `json:"behavior"`
|
|
158
|
+
UpdatedInput struct {
|
|
159
|
+
Questions json.RawMessage `json:"questions"`
|
|
160
|
+
Answers map[string]string `json:"answers"`
|
|
161
|
+
} `json:"updatedInput"`
|
|
162
|
+
}
|
|
163
|
+
if err := json.Unmarshal(out, &got); err != nil {
|
|
164
|
+
t.Fatalf("输出不是合法 JSON: %v (%s)", err, out)
|
|
165
|
+
}
|
|
166
|
+
if got.Behavior != "allow" {
|
|
167
|
+
t.Errorf("behavior = %q,期望 allow", got.Behavior)
|
|
168
|
+
}
|
|
169
|
+
// answers 的 key 必须是**问题原文**(最容易写错的一点)。
|
|
170
|
+
want := map[string]string{
|
|
171
|
+
"用什么格式?": "摘要",
|
|
172
|
+
"包含哪些章节?": "引言, 结论", // 多选按 ", " 连接
|
|
173
|
+
}
|
|
174
|
+
if len(got.UpdatedInput.Answers) != len(want) {
|
|
175
|
+
t.Fatalf("answers = %v,期望 %v", got.UpdatedInput.Answers, want)
|
|
176
|
+
}
|
|
177
|
+
for k, v := range want {
|
|
178
|
+
if got.UpdatedInput.Answers[k] != v {
|
|
179
|
+
t.Errorf("answers[%q] = %q,期望 %q", k, got.UpdatedInput.Answers[k], v)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// questions 必须**原样回传**(allow 时 updatedInput 是必填,且要带上原问题数组)。
|
|
183
|
+
if !strings.Contains(string(got.UpdatedInput.Questions), "用什么格式?") {
|
|
184
|
+
t.Errorf("questions 没原样回传: %s", got.UpdatedInput.Questions)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestEncodeAskAnswerFreeText(t *testing.T) {
|
|
189
|
+
req := q0Req(t)
|
|
190
|
+
out, err := EncodeAskAnswer("claude", req, AskAnswer{Choices: []AskChoice{
|
|
191
|
+
{QuestionID: "q0", FreeText: "Markdown"},
|
|
192
|
+
{QuestionID: "q1", Labels: []string{"引言"}},
|
|
193
|
+
}})
|
|
194
|
+
if err != nil {
|
|
195
|
+
t.Fatalf("渲染失败: %v", err)
|
|
196
|
+
}
|
|
197
|
+
// 自由文本直接当答案值(不要写 "Other")。
|
|
198
|
+
if !strings.Contains(string(out), `"用什么格式?":"Markdown"`) {
|
|
199
|
+
t.Errorf("自由文本没直接落到 answers 里: %s", out)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func TestEncodeAskAnswerDeny(t *testing.T) {
|
|
204
|
+
req := q0Req(t)
|
|
205
|
+
out, err := EncodeAskAnswer("claude", req, AskAnswer{Denied: true, Message: "用户拒绝了", Interrupt: true})
|
|
206
|
+
if err != nil {
|
|
207
|
+
t.Fatalf("渲染失败: %v", err)
|
|
208
|
+
}
|
|
209
|
+
var got map[string]any
|
|
210
|
+
if err := json.Unmarshal(out, &got); err != nil {
|
|
211
|
+
t.Fatalf("输出不是合法 JSON: %v", err)
|
|
212
|
+
}
|
|
213
|
+
if got["behavior"] != "deny" || got["message"] != "用户拒绝了" {
|
|
214
|
+
t.Errorf("拒绝结构不对: %v", got)
|
|
215
|
+
}
|
|
216
|
+
// claude 的官方 SDK 文档没有 interrupt 字段 → 不渲染(codebuddy 才渲染)。
|
|
217
|
+
if _, exists := got["interrupt"]; exists {
|
|
218
|
+
t.Errorf("claude 不该带 interrupt: %v", got)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
out2, err := EncodeAskAnswer("codebuddy", req, AskAnswer{Denied: true, Message: "用户拒绝了", Interrupt: true})
|
|
222
|
+
if err != nil {
|
|
223
|
+
t.Fatalf("codebuddy 渲染失败: %v", err)
|
|
224
|
+
}
|
|
225
|
+
var got2 map[string]any
|
|
226
|
+
if err := json.Unmarshal(out2, &got2); err != nil {
|
|
227
|
+
t.Fatalf("输出不是合法 JSON: %v", err)
|
|
228
|
+
}
|
|
229
|
+
if got2["interrupt"] != true {
|
|
230
|
+
t.Errorf("codebuddy 应带 interrupt: %v", got2)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func TestEncodeAskAnswerDenyDefaultMessage(t *testing.T) {
|
|
235
|
+
out, err := EncodeAskAnswer("claude", q0Req(t), AskAnswer{Denied: true})
|
|
236
|
+
if err != nil {
|
|
237
|
+
t.Fatalf("渲染失败: %v", err)
|
|
238
|
+
}
|
|
239
|
+
if !strings.Contains(string(out), `"message":"User declined"`) {
|
|
240
|
+
t.Errorf("拒绝时 message 为空应回默认文案: %s", out)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func TestEncodeAskAnswerPermissionKind(t *testing.T) {
|
|
245
|
+
req, ok := ParseAskLine("claude", askPermissionControlRequestDoc)
|
|
246
|
+
if !ok {
|
|
247
|
+
t.Fatal("素材没解析出来")
|
|
248
|
+
}
|
|
249
|
+
out, err := EncodeAskAnswer("claude", req, AskAnswer{})
|
|
250
|
+
if err != nil {
|
|
251
|
+
t.Fatalf("渲染失败: %v", err)
|
|
252
|
+
}
|
|
253
|
+
var got struct {
|
|
254
|
+
Behavior string `json:"behavior"`
|
|
255
|
+
UpdatedInput map[string]any `json:"updatedInput"`
|
|
256
|
+
}
|
|
257
|
+
if err := json.Unmarshal(out, &got); err != nil {
|
|
258
|
+
t.Fatalf("输出不是合法 JSON: %v", err)
|
|
259
|
+
}
|
|
260
|
+
if got.Behavior != "allow" || got.UpdatedInput["command"] != "npm test" {
|
|
261
|
+
t.Errorf("待授权放行应原样回传工具入参: %s", out)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func TestEncodeAskAnswerErrors(t *testing.T) {
|
|
266
|
+
dup := `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"c","name":"AskUserQuestion","input":{"questions":[
|
|
267
|
+
{"question":"同一个问题?","header":"A","multiSelect":false,"options":[{"label":"是"},{"label":"否"}]},
|
|
268
|
+
{"question":"同一个问题?","header":"B","multiSelect":false,"options":[{"label":"是"},{"label":"否"}]}
|
|
269
|
+
]}}]}}`
|
|
270
|
+
dupReq, ok := ParseAskLine("claude", dup)
|
|
271
|
+
if !ok {
|
|
272
|
+
t.Fatal("重复问题的素材没解析出来")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cases := []struct {
|
|
276
|
+
name string
|
|
277
|
+
engine string
|
|
278
|
+
req *AskRequest
|
|
279
|
+
ans AskAnswer
|
|
280
|
+
want string
|
|
281
|
+
}{
|
|
282
|
+
{"引擎不支持(无已知答案形状)", "trae", q0Req(t), AskAnswer{}, "has no known ask-answer protocol shape"},
|
|
283
|
+
{"问题原文重复(文本 key 无法区分)", "claude", dupReq, AskAnswer{Choices: []AskChoice{
|
|
284
|
+
{QuestionID: "q0", Labels: []string{"是"}}, {QuestionID: "q1", Labels: []string{"否"}},
|
|
285
|
+
}}, "appears more than once"},
|
|
286
|
+
{"漏答一个问题", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{{QuestionID: "q0", Labels: []string{"摘要"}}}}, "has no answer"},
|
|
287
|
+
{"选项不在候选里", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{
|
|
288
|
+
{QuestionID: "q0", Labels: []string{"幻觉选项"}}, {QuestionID: "q1", Labels: []string{"引言"}},
|
|
289
|
+
}}, "is not one of its options"},
|
|
290
|
+
{"单选给了多个 label", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{
|
|
291
|
+
{QuestionID: "q0", Labels: []string{"摘要", "详细"}}, {QuestionID: "q1", Labels: []string{"引言"}},
|
|
292
|
+
}}, "single-select"},
|
|
293
|
+
{"问题 id 不存在", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{
|
|
294
|
+
{QuestionID: "q9", Labels: []string{"摘要"}}, {QuestionID: "q1", Labels: []string{"引言"}},
|
|
295
|
+
}}, "unknown question id"},
|
|
296
|
+
{"空答案", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{
|
|
297
|
+
{QuestionID: "q0"}, {QuestionID: "q1", Labels: []string{"引言"}},
|
|
298
|
+
}}, "empty answer"},
|
|
299
|
+
{"labels 与 free_text 同时给", "claude", q0Req(t), AskAnswer{Choices: []AskChoice{
|
|
300
|
+
{QuestionID: "q0", Labels: []string{"摘要"}, FreeText: "X"}, {QuestionID: "q1", Labels: []string{"引言"}},
|
|
301
|
+
}}, "not both"},
|
|
302
|
+
}
|
|
303
|
+
for _, c := range cases {
|
|
304
|
+
t.Run(c.name, func(t *testing.T) {
|
|
305
|
+
_, err := EncodeAskAnswer(c.engine, c.req, c.ans)
|
|
306
|
+
if err == nil {
|
|
307
|
+
t.Fatal("期望报错,却成功了")
|
|
308
|
+
}
|
|
309
|
+
if !strings.Contains(err.Error(), c.want) {
|
|
310
|
+
t.Errorf("错误信息 = %q,期望含 %q", err.Error(), c.want)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
func TestEncodeAskControlResponse(t *testing.T) {
|
|
317
|
+
req, _ := ParseAskLine("claude", askControlRequestDoc)
|
|
318
|
+
out, err := EncodeAskControlResponse("claude", req, AskAnswer{Choices: []AskChoice{{QuestionID: "q0", Labels: []string{"PostgreSQL"}}}})
|
|
319
|
+
if err != nil {
|
|
320
|
+
t.Fatalf("渲染失败: %v", err)
|
|
321
|
+
}
|
|
322
|
+
var got struct {
|
|
323
|
+
Type string `json:"type"`
|
|
324
|
+
Response struct {
|
|
325
|
+
Subtype string `json:"subtype"`
|
|
326
|
+
RequestID string `json:"request_id"`
|
|
327
|
+
Response struct {
|
|
328
|
+
Behavior string `json:"behavior"`
|
|
329
|
+
} `json:"response"`
|
|
330
|
+
} `json:"response"`
|
|
331
|
+
}
|
|
332
|
+
if err := json.Unmarshal(out, &got); err != nil {
|
|
333
|
+
t.Fatalf("输出不是合法 JSON: %v (%s)", err, out)
|
|
334
|
+
}
|
|
335
|
+
if got.Type != "control_response" || got.Response.Subtype != "success" {
|
|
336
|
+
t.Errorf("信封不对: %s", out)
|
|
337
|
+
}
|
|
338
|
+
// request_id 必须原样带回(否则 CLI 对不上是哪条请求)。
|
|
339
|
+
if got.Response.RequestID != "perm-1" {
|
|
340
|
+
t.Errorf("request_id = %q,期望 perm-1", got.Response.RequestID)
|
|
341
|
+
}
|
|
342
|
+
if got.Response.Response.Behavior != "allow" {
|
|
343
|
+
t.Errorf("内层结果不对: %s", out)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// tool_use 形态没有 request_id → 明确报错(不静默造一个)。
|
|
347
|
+
toolUseReq, _ := ParseAskLine("claude", askToolUseLineReal)
|
|
348
|
+
if _, err := EncodeAskControlResponse("claude", toolUseReq, AskAnswer{}); err == nil {
|
|
349
|
+
t.Error("没有 request_id 时应报错")
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func TestEncodeAskFollowUp(t *testing.T) {
|
|
354
|
+
req := q0Req(t)
|
|
355
|
+
got := EncodeAskFollowUp(req, AskAnswer{Choices: []AskChoice{
|
|
356
|
+
{QuestionID: "q0", Labels: []string{"摘要"}},
|
|
357
|
+
{QuestionID: "q1", Labels: []string{"引言", "结论"}},
|
|
358
|
+
}})
|
|
359
|
+
for _, want := range []string{AskAnswerPrefix, "用什么格式? → 摘要", "包含哪些章节? → 引言, 结论"} {
|
|
360
|
+
if !strings.Contains(got, want) {
|
|
361
|
+
t.Errorf("兜底文本缺 %q:\n%s", want, got)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 未回答的问题显式标出来(不静默省略)。
|
|
366
|
+
partial := EncodeAskFollowUp(req, AskAnswer{Choices: []AskChoice{{QuestionID: "q0", Labels: []string{"摘要"}}}})
|
|
367
|
+
if !strings.Contains(partial, "(未回答)") {
|
|
368
|
+
t.Errorf("漏答的问题应显式标注:\n%s", partial)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 拒绝
|
|
372
|
+
denied := EncodeAskFollowUp(req, AskAnswer{Denied: true, Message: "不想回答"})
|
|
373
|
+
if !strings.Contains(denied, "用户拒绝回答") || !strings.Contains(denied, "不想回答") {
|
|
374
|
+
t.Errorf("拒绝文案不对:\n%s", denied)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 待授权
|
|
378
|
+
perm, _ := ParseAskLine("claude", askPermissionControlRequestDoc)
|
|
379
|
+
follow := EncodeAskFollowUp(perm, AskAnswer{})
|
|
380
|
+
if !strings.Contains(follow, "Bash → 允许") {
|
|
381
|
+
t.Errorf("待授权文案不对:\n%s", follow)
|
|
382
|
+
}
|
|
383
|
+
followDeny := EncodeAskFollowUp(perm, AskAnswer{Denied: true, Message: "命令有风险"})
|
|
384
|
+
if !strings.Contains(followDeny, "Bash → 拒绝") || !strings.Contains(followDeny, "命令有风险") {
|
|
385
|
+
t.Errorf("待授权拒绝文案不对:\n%s", followDeny)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// nil 请求不 panic
|
|
389
|
+
if s := EncodeAskFollowUp(nil, AskAnswer{}); !strings.Contains(s, AskPrefixForTest) {
|
|
390
|
+
t.Errorf("nil 请求也应给出前缀:\n%s", s)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// AskPrefixForTest 只是把常量暴露给上面那条断言,避免硬编码文案。
|
|
395
|
+
const AskPrefixForTest = AskAnswerPrefix
|
|
396
|
+
|
|
397
|
+
func TestAskSupportOf(t *testing.T) {
|
|
398
|
+
for _, e := range []string{"claude", "CLAUDE"} {
|
|
399
|
+
if got := AskSupportOf(e); got != "tool:AskUserQuestion" {
|
|
400
|
+
t.Errorf("AskSupportOf(%q) = %q", e, got)
|
|
401
|
+
}
|
|
402
|
+
if !AskSupportsEngine(e) {
|
|
403
|
+
t.Errorf("AskSupportsEngine(%q) 应为 true", e)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// codebuddy 族:模型看不到 AskUserQuestion(2026-09-28 七种配置实测,见 AskSupportOf 注释)。
|
|
407
|
+
// 回归保护:曾按"同族协议"推断成 tool:AskUserQuestion,导致上层 UI 承诺永不出现的决策卡。
|
|
408
|
+
for _, e := range []string{"codebuddy", "codebuddy-ai", "trae", "llm", "codex", "openclaw", "dsh", "arkclaw", "", "nope"} {
|
|
409
|
+
if got := AskSupportOf(e); got != "none" {
|
|
410
|
+
t.Errorf("AskSupportOf(%q) = %q,期望 none", e, got)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// interrupt 是 codebuddy 的**协议形状**(当前真机走不到,见 AskInterruptSupportOf 注释)。
|
|
414
|
+
if !AskInterruptSupportOf("codebuddy") || AskInterruptSupportOf("claude") {
|
|
415
|
+
t.Error("interrupt 支持判定不对(只有 codebuddy 的官方文档写了该字段)")
|
|
416
|
+
}
|
|
417
|
+
if !AskInterruptSupportOf("codebuddy-ai") {
|
|
418
|
+
t.Error("codebuddy-ai 与 codebuddy 同族同协议,应支持 interrupt")
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
func TestNormalizeAskHeader(t *testing.T) {
|
|
423
|
+
// claude 侧 header ≤12 字符;按**字符**(rune)截断,不能按字节。
|
|
424
|
+
long := "午餐选择问题标题很长超过十二个字符"
|
|
425
|
+
got := NormalizeAskHeader("claude", long)
|
|
426
|
+
if n := len([]rune(got)); n != 12 {
|
|
427
|
+
t.Errorf("截断后 = %d 个字符(%q),期望 12", n, got)
|
|
428
|
+
}
|
|
429
|
+
if got == long[:12] {
|
|
430
|
+
t.Error("按字节截断会把中文切碎,必须按 rune")
|
|
431
|
+
}
|
|
432
|
+
// codebuddy 无该限制 → 原样。
|
|
433
|
+
if NormalizeAskHeader("codebuddy", long) != long {
|
|
434
|
+
t.Error("codebuddy 不该截断 header")
|
|
435
|
+
}
|
|
436
|
+
// 未超长原样返回。
|
|
437
|
+
if NormalizeAskHeader("claude", "午餐") != "午餐" {
|
|
438
|
+
t.Error("未超长应原样返回")
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------- 流接入
|
|
443
|
+
|
|
444
|
+
// feedNDJSON 跑一遍 handleNDJSONLine,收集所有事件。
|
|
445
|
+
func feedNDJSON(t *testing.T, acc *streamAccumulator, lines []string) []StreamEvent {
|
|
446
|
+
t.Helper()
|
|
447
|
+
var got []StreamEvent
|
|
448
|
+
acc.OnEvent = func(ev StreamEvent) { got = append(got, ev) }
|
|
449
|
+
for i, l := range lines {
|
|
450
|
+
if _, err := acc.handleNDJSONLine(l); err != nil {
|
|
451
|
+
t.Fatalf("第 %d 行解析失败: %v", i+1, err)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return got
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// onlyAskEvents 过滤出 KindAsk 事件。
|
|
458
|
+
func onlyAskEvents(evs []StreamEvent) []StreamEvent {
|
|
459
|
+
var out []StreamEvent
|
|
460
|
+
for _, ev := range evs {
|
|
461
|
+
if ev.Kind == KindAsk {
|
|
462
|
+
out = append(out, ev)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return out
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// TestStreamEmitsAskFromPartialAndAggregatedOnce 覆盖 claude 的真实双路:
|
|
469
|
+
// --include-partial-messages 下,同一个提问既走 content_block 增量(tool_use 累积),
|
|
470
|
+
// 又走聚合 assistant 行 —— 两条路都必须能识别,且**只报一次**。
|
|
471
|
+
func TestStreamEmitsAskFromPartialAndAggregatedOnce(t *testing.T) {
|
|
472
|
+
partialJSON := `{\"questions\":[{\"question\":\"午餐吃拉面还是盖饭?\",\"header\":\"午餐选择\",\"multiSelect\":false,\"options\":[{\"label\":\"拉面\",\"description\":\"选拉面\"},{\"label\":\"盖饭\",\"description\":\"选盖饭\"}]}]}`
|
|
473
|
+
lines := []string{
|
|
474
|
+
`{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_01a0b32ddcb27515aa596902","name":"AskUserQuestion"}}}`,
|
|
475
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"` + partialJSON + `"}}}`,
|
|
476
|
+
`{"type":"stream_event","event":{"type":"content_block_stop","index":1}}`,
|
|
477
|
+
askToolUseLineReal, // 聚合行(同 id)
|
|
478
|
+
`{"type":"result","subtype":"success","result":"done","session_id":"s1"}`,
|
|
479
|
+
}
|
|
480
|
+
acc := &streamAccumulator{Engine: "claude"}
|
|
481
|
+
evs := feedNDJSON(t, acc, lines)
|
|
482
|
+
|
|
483
|
+
asks := onlyAskEvents(evs)
|
|
484
|
+
if len(asks) != 1 {
|
|
485
|
+
t.Fatalf("KindAsk 事件数 = %d,期望 1(去重失败):%+v", len(asks), asks)
|
|
486
|
+
}
|
|
487
|
+
ev := asks[0]
|
|
488
|
+
if ev.Ask == nil {
|
|
489
|
+
t.Fatal("KindAsk 必须带归一化后的 Ask")
|
|
490
|
+
}
|
|
491
|
+
if ev.Ask.Engine != "claude" || ev.Ask.Kind != AskKindQuestion {
|
|
492
|
+
t.Errorf("Ask 元信息不对: %+v", ev.Ask)
|
|
493
|
+
}
|
|
494
|
+
if len(ev.Ask.Questions) != 1 || ev.Ask.Questions[0].Options[1].Label != "盖饭" {
|
|
495
|
+
t.Errorf("增量累积出的问题不对: %+v", ev.Ask.Questions)
|
|
496
|
+
}
|
|
497
|
+
// Text 是一行人话摘要(text 模式打到 stderr 用)。
|
|
498
|
+
if !strings.Contains(ev.Text, "需要用户选择") || !strings.Contains(ev.Text, "拉面 | 盖饭") {
|
|
499
|
+
t.Errorf("摘要文本不对: %q", ev.Text)
|
|
500
|
+
}
|
|
501
|
+
// 原有工具事件不受影响(提问仍是一条 tool_use)。
|
|
502
|
+
var toolUse int
|
|
503
|
+
for _, e := range evs {
|
|
504
|
+
if e.Kind == KindToolUse {
|
|
505
|
+
toolUse++
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if toolUse != 1 {
|
|
509
|
+
t.Errorf("KindToolUse 事件数 = %d,期望 1", toolUse)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// TestStreamEmitsAskOnlyFromAggregated 未开 --include-partial-messages 时
|
|
514
|
+
// (带附件的非流式路径就是这种)只有聚合行,也必须识别出来。
|
|
515
|
+
func TestStreamEmitsAskOnlyFromAggregated(t *testing.T) {
|
|
516
|
+
acc := &streamAccumulator{Engine: "claude"}
|
|
517
|
+
evs := feedNDJSON(t, acc, []string{askToolUseLineReal, askAutoDenyLineReal})
|
|
518
|
+
asks := onlyAskEvents(evs)
|
|
519
|
+
if len(asks) != 1 {
|
|
520
|
+
t.Fatalf("KindAsk 事件数 = %d,期望 1", len(asks))
|
|
521
|
+
}
|
|
522
|
+
if asks[0].Ask.Source != AskSourceToolUse {
|
|
523
|
+
t.Errorf("Source = %q,期望 tool_use", asks[0].Ask.Source)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
func TestStreamEmitsAskFromControlRequest(t *testing.T) {
|
|
528
|
+
acc := &streamAccumulator{Engine: "codebuddy"}
|
|
529
|
+
evs := feedNDJSON(t, acc, []string{askControlRequestDoc})
|
|
530
|
+
asks := onlyAskEvents(evs)
|
|
531
|
+
if len(asks) != 1 {
|
|
532
|
+
t.Fatalf("KindAsk 事件数 = %d,期望 1", len(asks))
|
|
533
|
+
}
|
|
534
|
+
if asks[0].Ask.Source != AskSourceControlRequest || asks[0].Ask.RequestID != "perm-1" {
|
|
535
|
+
t.Errorf("control_request 归一化不对: %+v", asks[0].Ask)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// askToolUseLineNoSession 同上的提问行,但**不带** session_id —— 用来验证
|
|
540
|
+
// 「init 行给过的会话 id 能兜住后面不带 id 的提问行」。
|
|
541
|
+
const askToolUseLineNoSession = `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"call_ns_1","name":"AskUserQuestion","input":{"questions":[{"question":"午餐?","header":"午餐","multiSelect":false,"options":[{"label":"拉面"},{"label":"盖饭"}]}]}}]}}`
|
|
542
|
+
|
|
543
|
+
// TestStreamAskCarriesSessionID ask 事件必须带会话 id —— 宿主靠它把用户的答案
|
|
544
|
+
// `--append` 回同一会话(json 模式下 stderr 保持干净,拿不到启动提示里的 run_id)。
|
|
545
|
+
// 取「流里最近见到的」那个:init 行给过、提问行自己没带时,用 init 的。
|
|
546
|
+
func TestStreamAskCarriesSessionID(t *testing.T) {
|
|
547
|
+
lines := []string{
|
|
548
|
+
// init 行:流的最开头就带 session_id(提问出现时它已经在了)
|
|
549
|
+
`{"type":"system","subtype":"init","session_id":"s-ask-1","model":"m"}`,
|
|
550
|
+
askToolUseLineNoSession,
|
|
551
|
+
}
|
|
552
|
+
acc := &streamAccumulator{Engine: "claude"}
|
|
553
|
+
asks := onlyAskEvents(feedNDJSON(t, acc, lines))
|
|
554
|
+
if len(asks) != 1 {
|
|
555
|
+
t.Fatalf("KindAsk 事件数 = %d,期望 1", len(asks))
|
|
556
|
+
}
|
|
557
|
+
if asks[0].SessionID != "s-ask-1" {
|
|
558
|
+
t.Errorf("ask 事件没带 init 行的会话 id: %q", asks[0].SessionID)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// TestStreamAskSessionIDFromAssistantLine 即使没有 init 行,也要能从 assistant 行
|
|
563
|
+
// 自带的 session_id 兜住(真机抓包的那一行就带)。
|
|
564
|
+
func TestStreamAskSessionIDFromAssistantLine(t *testing.T) {
|
|
565
|
+
acc := &streamAccumulator{Engine: "claude"}
|
|
566
|
+
asks := onlyAskEvents(feedNDJSON(t, acc, []string{askToolUseLineReal}))
|
|
567
|
+
if len(asks) != 1 {
|
|
568
|
+
t.Fatalf("KindAsk 事件数 = %d,期望 1", len(asks))
|
|
569
|
+
}
|
|
570
|
+
if asks[0].SessionID != "ad0f2b93-7916-4d8a-8852-9756c179aff1" {
|
|
571
|
+
t.Errorf("应从 assistant 行的 session_id 兜住,got %q", asks[0].SessionID)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
func TestStreamNoAskForOtherTools(t *testing.T) {
|
|
576
|
+
lines := []string{
|
|
577
|
+
`{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"Bash"}}}`,
|
|
578
|
+
`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls\"}"}}}`,
|
|
579
|
+
`{"type":"stream_event","event":{"type":"content_block_stop","index":1}}`,
|
|
580
|
+
`{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Read","input":{"file_path":"/tmp/x"}}]}}`,
|
|
581
|
+
askAutoDenyLineReal,
|
|
582
|
+
}
|
|
583
|
+
acc := &streamAccumulator{Engine: "claude"}
|
|
584
|
+
if asks := onlyAskEvents(feedNDJSON(t, acc, lines)); len(asks) != 0 {
|
|
585
|
+
t.Errorf("非提问工具不该发 KindAsk: %+v", asks)
|
|
586
|
+
}
|
|
587
|
+
}
|