magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,239 @@
1
+ package cli
2
+
3
+ // attach_test.go - 「文件+提示词」(-a/--attach 附件)的 CLI 层测试。
4
+ //
5
+ // 用户需求原文(2026-09-17):「理解不对 是文件+提示词 比如截图加提示词」。
6
+ //
7
+ // 覆盖:-a 的路径解析与校验(缺失 / 目录 / 逗号 / 去重 / ~ 展开)、附件透传到 Request、
8
+ // 无原生附件通道的引擎(trae / openclaw)降级为「路径写进提示词」时的 stderr 提示、
9
+ // 以及 `--engines` 的 attachments 能力字段。
10
+
11
+ import (
12
+ "encoding/json"
13
+ "os"
14
+ "path/filepath"
15
+ "strings"
16
+ "testing"
17
+ )
18
+
19
+ // writeAttachFile 写一个测试文件(内容随意 —— MIME 由魔数/扩展名嗅探,这里只关心路径链路)。
20
+ func writeAttachFile(t *testing.T, name, content string) string {
21
+ t.Helper()
22
+ p := filepath.Join(t.TempDir(), name)
23
+ if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ return p
27
+ }
28
+
29
+ // ── resolveAttachments ───────────────────────────────────────
30
+
31
+ func TestResolveAttachmentsSplitsAndDedupes(t *testing.T) {
32
+ a := writeAttachFile(t, "a.png", "\x89PNG\r\n\x1a\n")
33
+ b := writeAttachFile(t, "b.md", "hi")
34
+
35
+ got, err := resolveAttachments([]string{a + "," + b, a}) // 逗号 + 重复
36
+ if err != nil {
37
+ t.Fatalf("resolveAttachments: %v", err)
38
+ }
39
+ if len(got) != 2 {
40
+ t.Fatalf("附件数 = %d want 2(去重后): %+v", len(got), got)
41
+ }
42
+ if got[0].Path != a || got[1].Path != b {
43
+ t.Errorf("路径应转绝对且保持顺序: %+v", got)
44
+ }
45
+ if got[0].MIME != "image/png" {
46
+ t.Errorf("a.png 的 MIME = %q want image/png(魔数)", got[0].MIME)
47
+ }
48
+ if got[1].MIME != "text/plain" {
49
+ t.Errorf("b.md 的 MIME = %q want text/plain", got[1].MIME)
50
+ }
51
+ }
52
+
53
+ func TestResolveAttachmentsErrors(t *testing.T) {
54
+ if _, err := resolveAttachments([]string{"/no/such/shot.png"}); err == nil {
55
+ t.Error("不存在的附件应报错")
56
+ }
57
+ if _, err := resolveAttachments([]string{t.TempDir()}); err == nil {
58
+ t.Error("目录应报错(不是普通文件)")
59
+ }
60
+ // 全空白 → 视作没有附件(不报错)
61
+ got, err := resolveAttachments([]string{" ", ","})
62
+ if err != nil || len(got) != 0 {
63
+ t.Errorf("空白输入应得到 0 个附件: %+v err=%v", got, err)
64
+ }
65
+ }
66
+
67
+ // ── -a/--attach 透传 ─────────────────────────────────────────
68
+
69
+ // 有原生附件通道的引擎(claude):附件进 Request,且不打降级警告。
70
+ func TestAttachFlagReachesRequest(t *testing.T) {
71
+ capEng := &capturingEngine{name: "claude"}
72
+ registerFake(capEng)
73
+ img := writeAttachFile(t, "shot.png", "\x89PNG\r\n\x1a\n")
74
+
75
+ _, stderr, err := runAskCmd(t, "", "-e", "claude", "-p", "这张图什么颜色", "-a", img)
76
+ if err != nil {
77
+ t.Fatalf("ask: %v", err)
78
+ }
79
+ if len(capEng.lastAttach) != 1 {
80
+ t.Fatalf("附件数 = %d want 1", len(capEng.lastAttach))
81
+ }
82
+ if capEng.lastAttach[0].Path != img {
83
+ t.Errorf("附件路径 = %q want %q", capEng.lastAttach[0].Path, img)
84
+ }
85
+ if capEng.lastAttach[0].MIME != "image/png" {
86
+ t.Errorf("附件 MIME = %q", capEng.lastAttach[0].MIME)
87
+ }
88
+ if strings.Contains(stderr, "没有附件输入通道") {
89
+ t.Errorf("claude 有原生通道,不该打降级警告: %q", stderr)
90
+ }
91
+ // 提示词本身不受影响
92
+ if capEng.lastPrompt != "这张图什么颜色" {
93
+ t.Errorf("prompt = %q", capEng.lastPrompt)
94
+ }
95
+ }
96
+
97
+ // 多个 -a 与逗号分隔等价。
98
+ func TestAttachFlagMultiple(t *testing.T) {
99
+ // 注意:不能用 "claude" 这个名字 —— 上一个用例已注册同名假引擎,
100
+ // Lookup 取注册表里首个匹配 → 断言会打到别的实例上(我第一版就这么挂的)。
101
+ capEng := &capturingEngine{name: "fake-attmulti"}
102
+ registerFake(capEng)
103
+ a := writeAttachFile(t, "1.png", "\x89PNG\r\n\x1a\n")
104
+ b := writeAttachFile(t, "2.png", "\x89PNG\r\n\x1a\n")
105
+ c := writeAttachFile(t, "3.png", "\x89PNG\r\n\x1a\n")
106
+
107
+ // -a 可重复,也接受逗号分隔
108
+ _, _, err := runAskCmd(t, "", "-e", "fake-attmulti", "-p", "看图", "-a", a, "-a", b+","+c)
109
+ if err != nil {
110
+ t.Fatalf("ask: %v", err)
111
+ }
112
+ if len(capEng.lastAttach) != 3 {
113
+ t.Errorf("附件数 = %d want 3(-a 重复 + 逗号分隔)", len(capEng.lastAttach))
114
+ }
115
+ }
116
+
117
+ // 附件路径错误 → usage error(exit 2),不静默忽略。
118
+ func TestAttachFlagBadPathIsUsageError(t *testing.T) {
119
+ registerFake(&stringEngine{name: "fake-attbad", text: "x"})
120
+
121
+ _, _, err := runAskCmd(t, "", "-e", "fake-attbad", "-p", "hi", "-a", "/no/such/shot.png")
122
+ if err == nil {
123
+ t.Fatal("应报错")
124
+ }
125
+ if _, ok := err.(*usageError); !ok {
126
+ t.Errorf("应是 usageError(exit 2), got %T: %v", err, err)
127
+ }
128
+ if !strings.Contains(err.Error(), "--attach") {
129
+ t.Errorf("错误信息应点明 --attach: %v", err)
130
+ }
131
+ }
132
+
133
+ // ── 无原生通道引擎的降级提示 ─────────────────────────────────
134
+
135
+ func TestAttachmentPromptFallbackWarnUnit(t *testing.T) {
136
+ // 有原生通道 → 无提示
137
+ for _, e := range []string{"claude", "codebuddy", "codebuddy-ai", "codex", "llm", "arkclaw"} {
138
+ if got := attachmentPromptFallbackWarn(e, 1, true); got != "" {
139
+ t.Errorf("%s 有原生通道,不该提示: %q", e, got)
140
+ }
141
+ }
142
+ // 无附件 → 无提示
143
+ if got := attachmentPromptFallbackWarn("trae", 0, true); got != "" {
144
+ t.Errorf("无附件不该提示: %q", got)
145
+ }
146
+ // trae + 工具开 → 提示但不提 --tools
147
+ got := attachmentPromptFallbackWarn("trae", 2, false)
148
+ if !strings.Contains(got, "trae") || !strings.Contains(got, "2 个附件") || strings.Contains(got, "--tools off") {
149
+ t.Errorf("trae/工具开 的提示不对: %q", got)
150
+ }
151
+ // trae + 工具关 → 必须点明读不到
152
+ gotOff := attachmentPromptFallbackWarn("trae", 1, true)
153
+ if !strings.Contains(gotOff, "--tools off") || !strings.Contains(gotOff, "--tools on") {
154
+ t.Errorf("工具关时必须点明:%q", gotOff)
155
+ }
156
+ if !strings.Contains(gotOff, "--engines") {
157
+ t.Errorf("提示应指引到 --engines 的 attachments 字段: %q", gotOff)
158
+ }
159
+ // dsh / openclaw:同样无原生通道,但 --tools 在它们身上没有落地通道
160
+ //(自带工具循环)→ 提示里**不该**出现「请改用 --tools on」(传了也不改变行为)。
161
+ for _, e := range []string{"dsh", "openclaw"} {
162
+ got := attachmentPromptFallbackWarn(e, 1, true)
163
+ if !strings.Contains(got, e) || !strings.Contains(got, "没有附件输入通道") {
164
+ t.Errorf("%s 应提示降级: %q", e, got)
165
+ }
166
+ if strings.Contains(got, "--tools on") {
167
+ t.Errorf("%s 的 --tools 无落地通道,不该提 --tools on: %q", e, got)
168
+ }
169
+ }
170
+ }
171
+
172
+ // 端到端:trae(无原生通道)带附件 + 默认 --tools off → stderr 出现降级提示。
173
+ func TestAttachFlagWarnsOnPromptFallbackEngine(t *testing.T) {
174
+ registerFake(&stringEngine{name: "trae", text: "ok"})
175
+ img := writeAttachFile(t, "shot.png", "\x89PNG\r\n\x1a\n")
176
+
177
+ _, stderr, err := runAskCmd(t, "", "-e", "trae", "-p", "看图", "-a", img)
178
+ if err != nil {
179
+ t.Fatalf("ask: %v", err)
180
+ }
181
+ if !strings.Contains(stderr, "没有附件输入通道") || !strings.Contains(stderr, "--tools off") {
182
+ t.Errorf("stderr 应给出降级提示, got %q", stderr)
183
+ }
184
+
185
+ // --tools on → 只提示降级,不再提读不到
186
+ _, stderrOn, err := runAskCmd(t, "", "-e", "trae", "-p", "看图", "--tools", "on", "-a", img)
187
+ if err != nil {
188
+ t.Fatalf("ask: %v", err)
189
+ }
190
+ if !strings.Contains(stderrOn, "没有附件输入通道") {
191
+ t.Errorf("仍应提示降级, got %q", stderrOn)
192
+ }
193
+ if strings.Contains(stderrOn, "--tools off") {
194
+ t.Errorf("工具已开,不该再提 --tools off: %q", stderrOn)
195
+ }
196
+ }
197
+
198
+ // ── --engines 的 attachments 字段 ────────────────────────────
199
+
200
+ func TestEnginesFlagCarriesAttachmentsCapability(t *testing.T) {
201
+ registerFake(&stringEngine{name: "codex"})
202
+ registerFake(&stringEngine{name: "claude"})
203
+ registerFake(&stringEngine{name: "arkclaw"})
204
+ registerFake(&stringEngine{name: "trae"})
205
+ registerFake(&stringEngine{name: "dsh"})
206
+
207
+ stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
208
+ if err != nil {
209
+ t.Fatalf("--engines: %v", err)
210
+ }
211
+ var rows []struct {
212
+ Engine string `json:"engine"`
213
+ Attachments string `json:"attachments"`
214
+ }
215
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
216
+ t.Fatalf("解析 --engines 输出失败: %v (%s)", err, stdout)
217
+ }
218
+ got := map[string]string{}
219
+ for _, r := range rows {
220
+ got[r.Engine] = r.Attachments
221
+ }
222
+ for engine, want := range map[string]string{
223
+ "codex": "flag:-i",
224
+ "claude": "stdin:stream-json",
225
+ "arkclaw": "part:file",
226
+ "trae": "prompt",
227
+ "dsh": "prompt", // headless 无附件参数 → 路径写进提示词
228
+ } {
229
+ if got[engine] != want {
230
+ t.Errorf("--engines 里 %s 的 attachments = %q want %q", engine, got[engine], want)
231
+ }
232
+ }
233
+ // 每条都必须有该字段(omitempty 也不能漏 —— 调用方据此决定要不要发附件)
234
+ for _, r := range rows {
235
+ if r.Attachments == "" {
236
+ t.Errorf("%s 缺 attachments 字段", r.Engine)
237
+ }
238
+ }
239
+ }
@@ -0,0 +1,157 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/darren/magic-agent/internal/agent"
9
+ )
10
+
11
+ // contractRow 是 --engines / --contract 每一行里本测试关心的字段。
12
+ type contractRow struct {
13
+ Engine string `json:"engine"`
14
+ OK bool `json:"ok"`
15
+ Capabilities []string `json:"capabilities"`
16
+ Models []string `json:"models"`
17
+ }
18
+
19
+ // TestContractEnvelope -- `--contract` 的顶层形状与契约版本。
20
+ //
21
+ // 客户端启动时靠这两个东西做校验:contractVersion 决定"我能不能解析这份输出",
22
+ // capabilities 决定"界面画成什么样"。
23
+ func TestContractEnvelope(t *testing.T) {
24
+ stdout, _, err := runAskCmd(t, "", "--contract")
25
+ if err != nil {
26
+ t.Fatalf("--contract: %v", err)
27
+ }
28
+ var env struct {
29
+ ContractVersion int `json:"contractVersion"`
30
+ Features []string `json:"features"`
31
+ Engines []contractRow `json:"engines"`
32
+ }
33
+ if err := json.Unmarshal([]byte(stdout), &env); err != nil {
34
+ t.Fatalf("解析 --contract 输出失败: %v(原始输出:%s)", err, stdout)
35
+ }
36
+ if env.ContractVersion != agent.ContractVersion {
37
+ t.Errorf("contractVersion = %d want %d", env.ContractVersion, agent.ContractVersion)
38
+ }
39
+ // 核心级通道能力:客户端据此判断这台 core 支不支持 --events / --control。
40
+ for _, want := range []string{agent.FeatureSessionEvents, agent.FeatureSessionControl} {
41
+ found := false
42
+ for _, f := range env.Features {
43
+ if f == want {
44
+ found = true
45
+ break
46
+ }
47
+ }
48
+ if !found {
49
+ t.Errorf("features 缺 %s,实际 %v", want, env.Features)
50
+ }
51
+ }
52
+ if got, want := len(env.Engines), len(agent.Engines()); got != want {
53
+ t.Errorf("engines 行数 = %d want %d", got, want)
54
+ }
55
+ for _, row := range env.Engines {
56
+ if row.Engine == "" {
57
+ t.Errorf("有一行缺 engine 字段:%+v", row)
58
+ }
59
+ if len(row.Capabilities) == 0 {
60
+ t.Errorf("%s: capabilities 为空(客户端会画不出这个引擎)", row.Engine)
61
+ }
62
+ /* ⚠️ --contract 默认不探测模型:每个引擎的模型探测都要起一次真 CLI 进程
63
+ (10 个引擎约 30s 级),客户端在启动路径上等不起。有人把默认改回探测时
64
+ 这条会红,正好提醒他去看 runContract 的注释。 */
65
+ if len(row.Models) != 0 {
66
+ t.Errorf("%s: --contract 默认不应探测模型,却给出了 models=%v", row.Engine, row.Models)
67
+ }
68
+ }
69
+ }
70
+
71
+ // TestEnginesTopLevelStaysArray -- `--engines` 顶层必须仍是数组。
72
+ //
73
+ // 老调用方(含 magic-test 的 engines.cjs)按数组解析;契约信息走 --contract 的
74
+ // envelope,不能把老入口改成对象 —— 那是静默破坏。
75
+ func TestEnginesTopLevelStaysArray(t *testing.T) {
76
+ stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
77
+ if err != nil {
78
+ t.Fatalf("--engines: %v", err)
79
+ }
80
+ trimmed := strings.TrimSpace(stdout)
81
+ if !strings.HasPrefix(trimmed, "[") {
82
+ t.Fatalf("--engines 顶层必须是 JSON 数组(老调用方按数组解析),实际开头是 %q", firstRunes(trimmed, 32))
83
+ }
84
+ }
85
+
86
+ // TestEnginesCarriesCapabilities -- 老入口也要带 capabilities(纯加字段,向后兼容)。
87
+ func TestEnginesCarriesCapabilities(t *testing.T) {
88
+ stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
89
+ if err != nil {
90
+ t.Fatalf("--engines: %v", err)
91
+ }
92
+ var rows []contractRow
93
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
94
+ t.Fatalf("解析 --engines 输出失败: %v(原始输出:%s)", err, stdout)
95
+ }
96
+ if len(rows) == 0 {
97
+ t.Fatal("--engines 没有任何一行")
98
+ }
99
+ for _, row := range rows {
100
+ if len(row.Capabilities) == 0 {
101
+ t.Errorf("%s: --engines 每行都应带 capabilities,实际为空", row.Engine)
102
+ }
103
+ }
104
+ }
105
+
106
+ // TestContractAndEnginesRowsAgree -- 两个入口的行必须由同一份构建逻辑产出:
107
+ // 同名引擎的 **capabilities**(静态声明)应逐项一致。
108
+ //
109
+ // ⚠️ 刻意**不比对 ok / streaming / models**:它们是运行态,两次独立进程的探测结果
110
+ // 可以合法地不同(arkclaw 这类远端网关的 ok 取决于可达性与配置)。这也正是把
111
+ // capabilities 与运行态分开的原因 —— 静态的那部分必须稳定,动态的那部分本就会变。
112
+ func TestContractAndEnginesRowsAgree(t *testing.T) {
113
+ contractOut, _, err := runAskCmd(t, "", "--contract")
114
+ if err != nil {
115
+ t.Fatalf("--contract: %v", err)
116
+ }
117
+ enginesOut, _, err := runAskCmd(t, "", "--engines", "--no-models")
118
+ if err != nil {
119
+ t.Fatalf("--engines: %v", err)
120
+ }
121
+
122
+ var env struct {
123
+ Engines []contractRow `json:"engines"`
124
+ }
125
+ var rows []contractRow
126
+ if err := json.Unmarshal([]byte(contractOut), &env); err != nil {
127
+ t.Fatalf("解析 --contract 失败: %v", err)
128
+ }
129
+ if err := json.Unmarshal([]byte(enginesOut), &rows); err != nil {
130
+ t.Fatalf("解析 --engines 失败: %v", err)
131
+ }
132
+
133
+ byName := map[string]contractRow{}
134
+ for _, r := range rows {
135
+ byName[r.Engine] = r
136
+ }
137
+ for _, c := range env.Engines {
138
+ e, ok := byName[c.Engine]
139
+ if !ok {
140
+ t.Errorf("--contract 里的 %s 在 --engines 里没有", c.Engine)
141
+ continue
142
+ }
143
+ if strings.Join(e.Capabilities, ",") != strings.Join(c.Capabilities, ",") {
144
+ t.Errorf("%s: capabilities 不一致(--engines=%v / --contract=%v)",
145
+ c.Engine, e.Capabilities, c.Capabilities)
146
+ }
147
+ }
148
+ }
149
+
150
+ // firstRunes 取前 n 个字符用于报错信息(避免把整段 JSON 打进日志)。
151
+ func firstRunes(s string, n int) string {
152
+ r := []rune(s)
153
+ if len(r) <= n {
154
+ return s
155
+ }
156
+ return string(r[:n]) + "…"
157
+ }
@@ -0,0 +1,149 @@
1
+ package cli
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "io"
7
+ "strings"
8
+ "sync"
9
+ "sync/atomic"
10
+ )
11
+
12
+ /* stdin 控制通道(--control):给流式会话一条**回传**通道。
13
+ *
14
+ * 为什么需要(2026-09-27):`--stream` 此前是单向的 —— 调用方只能看,不能打断长任务、
15
+ * 也不能回审批。而桌面 / 移动端最核心的两个动作恰好就是这两件(手机负责控制、
16
+ * 工作设备负责执行)。
17
+ *
18
+ * 协议:stdin 一行一条 NDJSON 命令(与 stdout 的事件流同构,便于同一套解析器处理)。
19
+ * 应答只在 `--events` 形态下输出(老形状不注入新事件类型):
20
+ *
21
+ * {"op":"ping"} → {"type":"pong"}
22
+ * {"op":"interrupt","reason":"..."} → 打断当前轮;**等这一轮真的结束**再发
23
+ * {"type":"interrupted","reason":"..."}
24
+ * {"op":"stop"} → 收工(关闭常驻会话的追加入口)→ {"type":"stopped"}
25
+ * {"op":"answer","text":"..."} → 把答案作为后续 user 消息追进常驻会话
26
+ * → {"type":"answered"};通道不可用 →
27
+ * {"type":"control_error","error":"..."}
28
+ * 其它 → {"type":"control_error","op":"..."}
29
+ *
30
+ * ⚠️ answer 为什么走「后续 user 消息」而不是原地作答:headless 下 claude 在模型提问后
31
+ * 会**立刻自行拒绝**(见 agent.KindAsk 的注释),唯一能把答案真正喂给模型的通道就是
32
+ * 下一轮 user 消息 —— 也就是常驻会话的追加通道,与 `--append` 是同一条路。
33
+ * 因此 answer 只在常驻会话生效时可用;不可用时明确回一条 control_error,不静默丢弃。
34
+ */
35
+
36
+ // controlCommand 一条控制命令。
37
+ type controlCommand struct {
38
+ Op string `json:"op"`
39
+ Reason string `json:"reason,omitempty"`
40
+ Text string `json:"text,omitempty"`
41
+ }
42
+
43
+ // controlHooks 控制命令的落地动作。
44
+ type controlHooks struct {
45
+ // interrupt 打断当前轮(取消 ctx → 引擎进程组随之被收拾)。
46
+ interrupt func(reason string)
47
+ // stop 收工:关掉常驻会话的追加入口,让引擎正常收尾。
48
+ stop func()
49
+ // answer 把答案追进常驻会话;返回 false 表示通道此刻不可用。
50
+ answer func(text string) bool
51
+ }
52
+
53
+ // controlSession 一次控制通道的生命周期。
54
+ type controlSession struct {
55
+ sink *eventSink
56
+ hooks controlHooks
57
+ stopFlg atomic.Bool
58
+ intrFlg atomic.Bool
59
+ closed chan struct{}
60
+ once sync.Once
61
+ }
62
+
63
+ // startControlReader 起一个后台 goroutine 读 NDJSON 命令。调用方负责 Close。
64
+ func startControlReader(r io.Reader, sink *eventSink, hooks controlHooks) *controlSession {
65
+ s := &controlSession{sink: sink, hooks: hooks, closed: make(chan struct{})}
66
+ go s.loop(r)
67
+ return s
68
+ }
69
+
70
+ func (s *controlSession) loop(r io.Reader) {
71
+ sc := bufio.NewScanner(r)
72
+ // 单条命令上限 4MiB:answer 可能带一段很长的说明(截图说明、报错粘贴)。
73
+ sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
74
+ for sc.Scan() {
75
+ select {
76
+ case <-s.closed:
77
+ return
78
+ default:
79
+ }
80
+ line := strings.TrimSpace(sc.Text())
81
+ if line == "" {
82
+ continue
83
+ }
84
+ var c controlCommand
85
+ if err := json.Unmarshal([]byte(line), &c); err != nil {
86
+ _ = s.sink.notice("control_error", map[string]any{"error": "不是合法 NDJSON 命令:" + err.Error()})
87
+ continue
88
+ }
89
+ s.dispatch(c)
90
+ }
91
+ }
92
+
93
+ // dispatch 执行一条命令。未知 op 只回一条 control_error,**不中断读取** ——
94
+ // 一个拼错的 op 不该让整条控制通道失效。
95
+ func (s *controlSession) dispatch(c controlCommand) {
96
+ switch strings.ToLower(strings.TrimSpace(c.Op)) {
97
+ case "ping":
98
+ _ = s.sink.notice("pong", nil)
99
+
100
+ case "interrupt":
101
+ // 不在这里回应答:interrupt 的真实含义是「这一轮真的结束了」,
102
+ // 由 runStreamAsk 在 Stream 返回后发 interrupted。
103
+ s.intrFlg.Store(true)
104
+ if s.hooks.interrupt != nil {
105
+ s.hooks.interrupt(c.Reason)
106
+ }
107
+
108
+ case "stop":
109
+ s.stopFlg.Store(true)
110
+ if s.hooks.stop != nil {
111
+ s.hooks.stop()
112
+ }
113
+ _ = s.sink.notice("stopped", nil)
114
+
115
+ case "answer":
116
+ text := strings.TrimSpace(c.Text)
117
+ if text == "" {
118
+ _ = s.sink.notice("control_error", map[string]any{"op": "answer", "error": "answer 需要 text 字段"})
119
+ return
120
+ }
121
+ if s.hooks.answer == nil || !s.hooks.answer(text) {
122
+ _ = s.sink.notice("control_error", map[string]any{
123
+ "op": "answer",
124
+ "error": "常驻会话未生效,answer 无处可去。" +
125
+ "答案要走常驻会话的追加通道(headless 下模型拿不到原地作答);" +
126
+ "请确认引擎支持 append(见 --contract 的 capabilities)且 --keep-alive 生效",
127
+ })
128
+ return
129
+ }
130
+ _ = s.sink.notice("answered", nil)
131
+
132
+ default:
133
+ _ = s.sink.notice("control_error", map[string]any{
134
+ "op": c.Op,
135
+ "error": "未知 op:支持 ping / interrupt / stop / answer",
136
+ })
137
+ }
138
+ }
139
+
140
+ // WasStopped 收到过 stop。
141
+ func (s *controlSession) WasStopped() bool { return s.stopFlg.Load() }
142
+
143
+ // WasInterrupted 收到过 interrupt(应答由调用方在本轮结束时发出)。
144
+ func (s *controlSession) WasInterrupted() bool { return s.intrFlg.Load() }
145
+
146
+ // Close 停止读取(幂等)。已经在阻塞读 stdin 的 goroutine 会在下一次 Scan 返回后退出。
147
+ func (s *controlSession) Close() {
148
+ s.once.Do(func() { close(s.closed) })
149
+ }