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,262 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ // TestSessionWriterLazyOpenAndId 先 push 几条事件(无 session_id),再 setID,
15
+ // 验证:(a) 没 id 时进 pending;(b) 有 id 后开文件并把 pending 也写进去。
16
+ func TestSessionWriterLazyOpenAndId(t *testing.T) {
17
+ dir := t.TempDir()
18
+ sw := newSessionWriter(dir, 1)
19
+ defer sw.close()
20
+
21
+ sw.push(sessionEvent{Kind: "thinking", Text: "先想想"})
22
+ sw.push(sessionEvent{Kind: "tool_use", Text: `{"file":"a.go"}`, Name: "Read", ID: "t1"})
23
+
24
+ sw.setSessionID("sess-1")
25
+ sw.push(sessionEvent{Kind: "text", Text: "你好"})
26
+
27
+ drain(t, sw)
28
+
29
+ lines := readLines(t, filepath.Join(dir, "sess-1.jsonl"))
30
+ if len(lines) != 3 {
31
+ t.Fatalf("期望 3 行历史,实际 %d:%v", len(lines), lines)
32
+ }
33
+ var first, second, third sessionEvent
34
+ if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ if err := json.Unmarshal([]byte(lines[2]), &third); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if first.Kind != "thinking" || second.Kind != "tool_use" || third.Kind != "text" {
44
+ t.Fatalf("顺序不对:%s / %s / %s", first.Kind, second.Kind, third.Kind)
45
+ }
46
+ if third.SessionID != "sess-1" {
47
+ t.Fatalf("第三行应带 session_id=sess-1,实得 %q", third.SessionID)
48
+ }
49
+ if first.SessionID != "sess-1" {
50
+ t.Fatalf("pending 行回填 id 失败:%q", first.SessionID)
51
+ }
52
+ if first.Seq >= second.Seq || second.Seq >= third.Seq {
53
+ t.Fatalf("Seq 不单调:%d/%d/%d", first.Seq, second.Seq, third.Seq)
54
+ }
55
+ }
56
+
57
+ // TestSessionWriterFilePermissions 落盘文件权限 0600。
58
+ func TestSessionWriterFilePermissions(t *testing.T) {
59
+ if os.Getuid() == 0 {
60
+ t.Skip("root 下 0600 检查无意义")
61
+ }
62
+ dir := t.TempDir()
63
+ sw := newSessionWriter(dir, 1)
64
+ sw.setSessionID("perm-1")
65
+ sw.push(sessionEvent{Kind: "text", Text: "你好"})
66
+ drain(t, sw)
67
+ st, err := os.Stat(filepath.Join(dir, "perm-1.jsonl"))
68
+ if err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ if perm := st.Mode().Perm(); perm != 0o600 {
72
+ t.Fatalf("期望 0600 权限,实得 %v", perm)
73
+ }
74
+ }
75
+
76
+ // TestSessionWriterAppendMode 第二次开启相同 id 应在文件末尾续写,
77
+ // 而不是覆盖 —— 续接同一会话时尤其重要(避免重复第一条事件)。
78
+ func TestSessionWriterAppendMode(t *testing.T) {
79
+ dir := t.TempDir()
80
+ sw1 := newSessionWriter(dir, 1)
81
+ sw1.setSessionID("sess-X")
82
+ sw1.push(sessionEvent{Kind: "text", Text: "first"})
83
+ drain(t, sw1)
84
+ sw1.close()
85
+
86
+ sw2 := newSessionWriter(dir, 2)
87
+ sw2.setSessionID("sess-X")
88
+ sw2.push(sessionEvent{Kind: "text", Text: "second"})
89
+ drain(t, sw2)
90
+ sw2.close()
91
+
92
+ lines := readLines(t, filepath.Join(dir, "sess-X.jsonl"))
93
+ if len(lines) != 2 {
94
+ t.Fatalf("期望 2 行(不覆盖),实得 %d", len(lines))
95
+ }
96
+ var a, b sessionEvent
97
+ if err := json.Unmarshal([]byte(lines[0]), &a); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if err := json.Unmarshal([]byte(lines[1]), &b); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if a.Text != "first" || b.Text != "second" {
104
+ t.Fatalf("顺序或内容错:%q / %q", a.Text, b.Text)
105
+ }
106
+ if a.Seq < 1 || b.Seq < 1 {
107
+ t.Fatalf("Seq 至少从 1 起:a=%d b=%d", a.Seq, b.Seq)
108
+ }
109
+ }
110
+
111
+ // TestReadSessionLogMissing 没历史时不应 500 / 应返回 nil。
112
+ func TestReadSessionLogMissing(t *testing.T) {
113
+ dir := t.TempDir()
114
+ cli := SessionLogDirOverride
115
+ defer func() { SessionLogDirOverride = cli }()
116
+ SessionLogDirOverride = dir
117
+ evs, err := ReadSessionLog("", "no-such-session")
118
+ if err != nil {
119
+ t.Fatalf("不存在的会话应该静默 OK:%v", err)
120
+ }
121
+ if evs != nil {
122
+ t.Fatalf("不存在的会话应该返回 nil:%v", evs)
123
+ }
124
+ }
125
+
126
+ // TestReadSessionLogSkipsCorruptLines 坏行不阻塞整体读取。
127
+ func TestReadSessionLogSkipsCorruptLines(t *testing.T) {
128
+ dir := t.TempDir()
129
+ path := filepath.Join(dir, "s.jsonl")
130
+ f, err := os.Create(path)
131
+ if err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ _, _ = f.WriteString(`{"seq":1,"kind":"text","text":"a","at":"2026-01-01T00:00:00Z","session_id":"s"}
135
+ {not json
136
+ {"seq":2,"kind":"text","text":"b","at":"2026-01-01T00:00:01Z","session_id":"s"}
137
+ `)
138
+ _ = f.Close()
139
+ cli := SessionLogDirOverride
140
+ defer func() { SessionLogDirOverride = cli }()
141
+ SessionLogDirOverride = dir
142
+ evs, err := ReadSessionLog("", "s")
143
+ if err != nil {
144
+ t.Fatalf("读历史失败:%v", err)
145
+ }
146
+ if len(evs) != 2 {
147
+ t.Fatalf("坏行应跳过,剩 2 行,实际 %d", len(evs))
148
+ }
149
+ if evs[0].Text != "a" || evs[1].Text != "b" {
150
+ t.Fatalf("顺序错:%v", evs)
151
+ }
152
+ }
153
+
154
+ // TestSessionWriterRealisticStream 模拟「真实流」的写入模式:
155
+ // - 首条事件(带 init session_id)→ writer 拿到 id → 开文件 + 把 pending 写盘
156
+ // - 后续 thinking/text/tool_use/turn_end 都按时间顺序落盘
157
+ // - 文件按行追加;可被 ReadSessionLog 完整读回
158
+ //
159
+ // 关键不变量:流路径断掉(写盘慢)时 pending 不丢 id,open 完就立即 flush;
160
+ // seq 单调递增;session_id 在 id 出现前后的行都能拿到。
161
+ func TestSessionWriterRealisticStream(t *testing.T) {
162
+ dir := t.TempDir()
163
+ cli := SessionLogDirOverride
164
+ defer func() { SessionLogDirOverride = cli }()
165
+ SessionLogDirOverride = dir
166
+
167
+ sw, err := newSessionWriterAuto(sessionLogDir)
168
+ if err != nil {
169
+ t.Fatal(err)
170
+ }
171
+
172
+ // 模拟 handleNDJSONLine 的边写边喂:先 3 条无 id,再设 id(init),再 4 条。
173
+ sw.push(sessionEvent{Kind: "thinking", Text: "我应该"})
174
+ sw.push(sessionEvent{Kind: "thinking", Text: "回「收到」"})
175
+ sw.push(sessionEvent{Kind: "text", Text: "用户让我只回「收到」"})
176
+ sw.setSessionID("sess-real")
177
+ sw.push(sessionEvent{Kind: "text", Text: "收到"})
178
+ sw.push(sessionEvent{Kind: "tool_use", Name: "Read", ID: "t1", Text: `{"file":"x"}`})
179
+ sw.push(sessionEvent{Kind: "tool_result", ID: "t1", Text: "ok"})
180
+ sw.push(sessionEvent{Kind: "turn_end", Text: "收到"})
181
+
182
+ drain(t, sw)
183
+
184
+ lines := readLines(t, filepath.Join(dir, "sess-real.jsonl"))
185
+ if len(lines) != 7 {
186
+ t.Fatalf("期望 7 行,实得 %d:\n%v", len(lines), lines)
187
+ }
188
+
189
+ want := []string{"thinking", "thinking", "text", "text", "tool_use", "tool_result", "turn_end"}
190
+ for i, ln := range lines {
191
+ var ev sessionEvent
192
+ if err := json.Unmarshal([]byte(ln), &ev); err != nil {
193
+ t.Fatalf("line %d 解析失败: %v", i, err)
194
+ }
195
+ if ev.Kind != want[i] {
196
+ t.Fatalf("line %d: 期望 kind=%s 实得 %s", i, want[i], ev.Kind)
197
+ }
198
+ if ev.SessionID != "sess-real" {
199
+ t.Fatalf("line %d: 所有行都应带 session_id,实得 %q", i, ev.SessionID)
200
+ }
201
+ if i > 0 {
202
+ var prev sessionEvent
203
+ _ = json.Unmarshal([]byte(lines[i-1]), &prev)
204
+ if ev.Seq <= prev.Seq {
205
+ t.Fatalf("line %d: Seq 必须严格递增 a=%d b=%d", i, prev.Seq, ev.Seq)
206
+ }
207
+ }
208
+ }
209
+
210
+ // 反向验证 —— ReadSessionLog 能完整读回 7 条。
211
+ evs, err := ReadSessionLog("", "sess-real")
212
+ if err != nil {
213
+ t.Fatalf("ReadSessionLog 失败:%v", err)
214
+ }
215
+ if len(evs) != 7 {
216
+ t.Fatalf("ReadSessionLog 期望 7 条,实得 %d", len(evs))
217
+ }
218
+ }
219
+
220
+ // drain 等 writer 的 in 通道全部写完。close 之后再等它把所有 pending 全 flush。
221
+ func drain(t *testing.T, sw *sessionWriter) {
222
+ t.Helper()
223
+ sw.close()
224
+ }
225
+
226
+ func readLines(t *testing.T, path string) []string {
227
+ t.Helper()
228
+ f, err := os.Open(path)
229
+ if err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ defer f.Close()
233
+ b, err := io.ReadAll(f)
234
+ if err != nil {
235
+ t.Fatal(err)
236
+ }
237
+ if len(b) == 0 {
238
+ return nil
239
+ }
240
+ return strings.Split(strings.TrimRight(string(b), "\n"), "\n")
241
+ }
242
+
243
+ // 保留一个简单的并发 sanity:setID 与 push 并发调用,writer 不能崩。
244
+ func TestSessionWriterConcurrentSetIDAndPush(t *testing.T) {
245
+ dir := t.TempDir()
246
+ sw := newSessionWriter(dir, 1)
247
+ var wg sync.WaitGroup
248
+ for i := 0; i < 50; i++ {
249
+ wg.Add(2)
250
+ go func(i int) {
251
+ defer wg.Done()
252
+ sw.setSessionID("id-" + string(rune('a'+i%26)))
253
+ }(i)
254
+ go func(i int) {
255
+ defer wg.Done()
256
+ sw.push(sessionEvent{Kind: "text", Text: "msg"})
257
+ time.Sleep(time.Millisecond)
258
+ }(i)
259
+ }
260
+ wg.Wait()
261
+ sw.close()
262
+ }
@@ -0,0 +1,129 @@
1
+ package cli
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ "github.com/darren/magic-agent/internal/agent"
10
+ )
11
+
12
+ // 用户提问落盘(kind:"user")的端到端验证。
13
+ //
14
+ // 为什么值得一条端到端测试:这个 push 发生在 **session_id 还不知道**的时刻
15
+ //(引擎要到流里才报 id),所以它落盘的正确性完全依赖 sessionWriter 的 pending 缓冲
16
+ //「拿到 id 后先刷 pending、再写当前事件」这个顺序。单看代码容易以为要手动等 id ——
17
+ // 一旦谁把它改成「等 id 再 push」,就会把流路径堵住(违反「绝不断流」),或者顺序错乱。
18
+ //
19
+ // 这一条钉住三件事:
20
+ // 1. 用户提问**确实**落盘了(在此之前历史里只有 agent 的单方面发言);
21
+ // 2. 它是**第一条**(排在引擎事件之前 —— 证明 pending 被先刷出);
22
+ // 3. seq 从 1 起且严格递增(历史接口靠 seq 做游标,断了就续读不成)。
23
+
24
+ // userEchoStreamEngine 模拟真实引擎:在流**开始时**报出 session_id(真实引擎在 init 行里报),
25
+ // 然后发 thinking / text。这样 userEvent 的 push 必然早于 id 的出现。
26
+ type userEchoStreamEngine struct {
27
+ name string
28
+ sessionID string
29
+ }
30
+
31
+ func (e *userEchoStreamEngine) Name() string { return e.name }
32
+ func (e *userEchoStreamEngine) Detect() (bool, string) { return true, "fake://" + e.name }
33
+
34
+ func (e *userEchoStreamEngine) Complete(_ context.Context, _ agent.Request) (agent.Response, error) {
35
+ return agent.Response{Text: "收到", Model: "fake-model"}, nil
36
+ }
37
+
38
+ func (e *userEchoStreamEngine) Stream(_ context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
39
+ // 引擎流里见到 session_id 的那一刻 —— 这是 writer 开文件的触发点。
40
+ if req.OnSessionID != nil {
41
+ req.OnSessionID(e.sessionID)
42
+ }
43
+ onEvent(agent.StreamEvent{Kind: agent.KindThinking, Text: "想一下"})
44
+ onEvent(agent.StreamEvent{Kind: agent.KindText, Text: "收到"})
45
+ return agent.StreamResult{
46
+ Response: agent.Response{Engine: e.name, Text: "收到", Model: "fake-model", SessionID: e.sessionID},
47
+ }, nil
48
+ }
49
+
50
+ func TestStreamAskPersistsUserMessage(t *testing.T) {
51
+ dir := t.TempDir()
52
+ prev := SessionLogDirOverride
53
+ SessionLogDirOverride = dir
54
+ defer func() { SessionLogDirOverride = prev }()
55
+
56
+ const sid = "sess-user-log"
57
+ const prompt = "帮我把 guard 的令牌比较改成常数时间,避免时序侧信道"
58
+ registerFake(&userEchoStreamEngine{name: "fake-user-log", sessionID: sid})
59
+
60
+ if _, _, err := runAskCmd(t, "", "-e", "fake-user-log", "--stream", prompt); err != nil {
61
+ t.Fatalf("--stream: %v", err)
62
+ }
63
+
64
+ // runStreamAsk 用 defer sessionLog.close() 收尾,所以命令返回后文件已 flush。
65
+ evs, err := ReadSessionLog(dir, sid)
66
+ if err != nil {
67
+ t.Fatalf("读回会话日志失败:%v", err)
68
+ }
69
+ if len(evs) == 0 {
70
+ t.Fatal("会话日志是空的 —— 用户提问没落盘")
71
+ }
72
+
73
+ // ① 首条必须是 user,且文本原样。
74
+ if evs[0].Kind != "user" {
75
+ t.Fatalf("首条期望 kind=user(用户提问应先于引擎事件落盘),实得 %q;全部:%s", evs[0].Kind, dumpKinds(evs))
76
+ }
77
+ if evs[0].Text != prompt {
78
+ t.Fatalf("首条 user 文本被改动:\n期望 %q\n实得 %q", prompt, evs[0].Text)
79
+ }
80
+ if evs[0].SessionID != sid {
81
+ t.Fatalf("首条应带上 session_id(pending 刷盘时回填),实得 %q", evs[0].SessionID)
82
+ }
83
+
84
+ // ② 引擎事件也都在,且排在 user 之后。
85
+ sawText := false
86
+ for i := 1; i < len(evs); i++ {
87
+ if evs[i].Kind == "text" && evs[i].Text == "收到" {
88
+ sawText = true
89
+ }
90
+ }
91
+ if !sawText {
92
+ t.Fatalf("引擎的 text 事件没落盘;全部:%s", dumpKinds(evs))
93
+ }
94
+
95
+ // ③ seq 从 1 起、严格递增(历史接口靠它做游标)。
96
+ for i, ev := range evs {
97
+ if ev.Seq != uint64(i+1) {
98
+ t.Fatalf("第 %d 条 seq 期望 %d,实得 %d(seq 必须从 1 起连续递增)", i, i+1, ev.Seq)
99
+ }
100
+ }
101
+
102
+ // ④ 落盘文件与 CLI 的 schema 一致:kind 字段(不是 type),这也是移动端渲染器
103
+ // 必须两套都认的原因。
104
+ raw := readLines(t, filepath.Join(dir, sid+".jsonl"))
105
+ if len(raw) == 0 {
106
+ t.Fatal("落盘文件是空的")
107
+ }
108
+ var first map[string]any
109
+ if err := json.Unmarshal([]byte(raw[0]), &first); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if first["kind"] != "user" {
113
+ t.Fatalf("落盘首行的字段应是 kind=user,实得 %v", first)
114
+ }
115
+ if _, hasType := first["type"]; hasType {
116
+ t.Fatal("落盘格式用的是 kind,不该出现 type 字段")
117
+ }
118
+ }
119
+
120
+ func dumpKinds(evs []sessionEvent) string {
121
+ out := ""
122
+ for i, ev := range evs {
123
+ if i > 0 {
124
+ out += ", "
125
+ }
126
+ out += ev.Kind
127
+ }
128
+ return "[" + out + "]"
129
+ }
@@ -0,0 +1,84 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+
7
+ "github.com/darren/magic-agent/internal/agent"
8
+ )
9
+
10
+ /* 工具子形态上 wire 的证据(2026-09-29)。
11
+ *
12
+ * 为什么值得单独钉一条:这个字段是**两个界面**(旧工作台 / PC 基础对话 UI)选卡的唯一依据,
13
+ * 而它只在 tool_use 事件上出现。没有这条断言,某天有人在 payload 里漏带它,
14
+ * 表现是「工具卡悄悄退回按名字猜」—— 界面上不报错,只是慢慢和新引擎对不上。 */
15
+ func TestStreamPayloadCarriesToolKind(t *testing.T) {
16
+ // ① 工具调用:带上判定结果
17
+ p := streamEventPayload(agent.StreamEvent{Kind: agent.KindToolUse, Name: "Bash", Text: `{"cmd":"ls"}`})
18
+ b, err := json.Marshal(p)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ var got map[string]any
23
+ if err := json.Unmarshal(b, &got); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if got["tool_kind"] != agent.ToolKindCommand {
27
+ t.Errorf("tool_use 的 tool_kind = %v, want %q(payload: %s)", got["tool_kind"], agent.ToolKindCommand, b)
28
+ }
29
+
30
+ // ② MCP 的名字带命名空间前缀,仍然要判成 mcp
31
+ b2, _ := json.Marshal(streamEventPayload(agent.StreamEvent{Kind: agent.KindToolUse, Name: "mcp__github__create_issue"}))
32
+ var got2 map[string]any
33
+ _ = json.Unmarshal(b2, &got2)
34
+ if got2["tool_kind"] != agent.ToolKindMCP {
35
+ t.Errorf("mcp 工具的 tool_kind = %v, want %q", got2["tool_kind"], agent.ToolKindMCP)
36
+ }
37
+
38
+ // ③ 工具**结果**不带:它的 Name 放的是关联的 tool_use.id,拿来判形态会把 id 当工具名
39
+ b3, _ := json.Marshal(streamEventPayload(agent.StreamEvent{Kind: agent.KindToolResult, Name: "toolu_01abc", Text: "ok"}))
40
+ var got3 map[string]any
41
+ _ = json.Unmarshal(b3, &got3)
42
+ if _, has := got3["tool_kind"]; has {
43
+ t.Errorf("tool_result 不该带 tool_kind:%s", b3)
44
+ }
45
+
46
+ // ④ 非工具事件:字段整个省掉(老消费者的字段集不受影响)
47
+ b4, _ := json.Marshal(streamEventPayload(agent.StreamEvent{Kind: agent.KindText, Text: "你好"}))
48
+ var got4 map[string]any
49
+ _ = json.Unmarshal(b4, &got4)
50
+ if _, has := got4["tool_kind"]; has {
51
+ t.Errorf("text 事件不该带 tool_kind:%s", b4)
52
+ }
53
+ }
54
+
55
+ /* 会话日志与 SSE 用**同一个**判定:两边画出的卡才会是同一张。
56
+ * 这里钉住日志事件的字段(history 回放走的就是它)。 */
57
+ func TestSessionEventCarriesToolKind(t *testing.T) {
58
+ ev := agent.StreamEvent{Kind: agent.KindToolUse, Name: "Edit", Text: `{"file":"a.go"}`}
59
+ se := sessionEvent{
60
+ Kind: string(ev.Kind), Text: ev.Text, Name: ev.Name, ID: ev.ID,
61
+ ToolKind: toolKindOfEvent(ev),
62
+ }
63
+ if se.ToolKind != agent.ToolKindFileChange {
64
+ t.Errorf("日志里的 tool_kind = %q, want %q", se.ToolKind, agent.ToolKindFileChange)
65
+ }
66
+ b, err := json.Marshal(se)
67
+ if err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ var got map[string]any
71
+ if err := json.Unmarshal(b, &got); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ if got["tool_kind"] != agent.ToolKindFileChange {
75
+ t.Errorf("日志事件的 tool_kind = %v, want %q(%s)", got["tool_kind"], agent.ToolKindFileChange, b)
76
+ }
77
+ // 非工具事件不带这个键(omitempty)
78
+ b2, _ := json.Marshal(sessionEvent{Kind: "text", Text: "hi"})
79
+ var got2 map[string]any
80
+ _ = json.Unmarshal(b2, &got2)
81
+ if _, has := got2["tool_kind"]; has {
82
+ t.Errorf("text 日志事件不该带 tool_kind:%s", b2)
83
+ }
84
+ }
@@ -0,0 +1,188 @@
1
+ package cli
2
+
3
+ // workspace_test.go - `-w/--workspace` 的 CLI 侧单测。
4
+ //
5
+ // 覆盖:
6
+ // ① 值解析:展开 ~、转绝对路径、必须存在且是目录(不存在/是文件 → 参数错误 exit 2 语义)
7
+ // ② 透传:进入 Request.Workspace(假引擎回收)
8
+ // ③ 不支持要说明白:llm / arkclaw 给出 stderr 提示文案,其余引擎不提示
9
+ // ④ --engines 每行带 workspace 能力字段(机器可读)
10
+
11
+ import (
12
+ "context"
13
+ "encoding/json"
14
+ "os"
15
+ "path/filepath"
16
+ "strings"
17
+ "testing"
18
+
19
+ "github.com/darren/magic-agent/internal/agent"
20
+ )
21
+
22
+ // wsCaptureEngine 记录收到的 Request.Workspace。
23
+ type wsCaptureEngine struct {
24
+ name string
25
+ got string
26
+ }
27
+
28
+ func (e *wsCaptureEngine) Name() string { return e.name }
29
+ func (e *wsCaptureEngine) Detect() (bool, string) { return true, "fake://" + e.name }
30
+ func (e *wsCaptureEngine) Complete(_ context.Context, req agent.Request) (agent.Response, error) {
31
+ e.got = req.Workspace
32
+ return agent.Response{Text: "ok", Model: req.Model}, nil
33
+ }
34
+
35
+ func TestWorkspaceFlagPassedToEngine(t *testing.T) {
36
+ ws := t.TempDir()
37
+ e := &wsCaptureEngine{name: "ws-echo"}
38
+ registerFake(e)
39
+
40
+ stdout, stderr, err := runAskCmd(t, "", "-e", "ws-echo", "-w", ws, "-o", "text", "hi")
41
+ if err != nil {
42
+ t.Fatalf("ask: %v (stderr=%s)", err, stderr)
43
+ }
44
+ if !strings.Contains(stdout, "ok") {
45
+ t.Errorf("stdout = %q", stdout)
46
+ }
47
+ // 路径会转绝对 + 规整(t.TempDir 在 macOS 上是 /var → /private/var 的软链)
48
+ want, _ := filepath.EvalSymlinks(ws)
49
+ got, _ := filepath.EvalSymlinks(e.got)
50
+ if got != want {
51
+ t.Errorf("Request.Workspace = %q, want %q", got, want)
52
+ }
53
+ // 支持的引擎不该有「不支持」提示
54
+ if strings.Contains(stderr, "不支持指定工作目录") {
55
+ t.Errorf("ws-echo 不该被提示不支持:%s", stderr)
56
+ }
57
+ }
58
+
59
+ func TestWorkspaceFlagExpandsTilde(t *testing.T) {
60
+ home, err := os.UserHomeDir()
61
+ if err != nil {
62
+ t.Skip("no home dir")
63
+ }
64
+ if _, err := resolveWorkspaceDir("~"); err != nil {
65
+ t.Fatalf(`resolveWorkspaceDir("~") = %v`, err)
66
+ }
67
+ got, err := resolveWorkspaceDir("~")
68
+ if err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ if want, _ := filepath.EvalSymlinks(home); func() bool { g, _ := filepath.EvalSymlinks(got); return g != want }() {
72
+ t.Errorf(`resolveWorkspaceDir("~") = %q, want %q`, got, home)
73
+ }
74
+ }
75
+
76
+ func TestWorkspaceFlagRejectsBadPath(t *testing.T) {
77
+ registerFake(&wsCaptureEngine{name: "ws-echo-bad"})
78
+
79
+ // 不存在
80
+ _, _, err := runAskCmd(t, "", "-e", "ws-echo-bad", "-w", filepath.Join(t.TempDir(), "nope"), "hi")
81
+ if err == nil || !strings.Contains(err.Error(), "--workspace") {
82
+ t.Errorf("不存在的目录应报参数错误并点明 --workspace,实得 %v", err)
83
+ }
84
+
85
+ // 是文件不是目录
86
+ f := filepath.Join(t.TempDir(), "afile")
87
+ if werr := os.WriteFile(f, []byte("x"), 0o644); werr != nil {
88
+ t.Fatal(werr)
89
+ }
90
+ _, _, err = runAskCmd(t, "", "-e", "ws-echo-bad", "-w", f, "hi")
91
+ if err == nil || !strings.Contains(err.Error(), "不是目录") {
92
+ t.Errorf("指向文件应报「不是目录」,实得 %v", err)
93
+ }
94
+ }
95
+
96
+ func TestWorkspaceUnsupportedWarnText(t *testing.T) {
97
+ for _, engine := range []string{"llm", "arkclaw", "openclaw"} {
98
+ warn := workspaceUnsupportedWarn(engine)
99
+ if !strings.Contains(warn, engine) || !strings.Contains(warn, "-w/--workspace 已忽略") {
100
+ t.Errorf("workspaceUnsupportedWarn(%q) = %q,应说明被忽略", engine, warn)
101
+ }
102
+ }
103
+ for _, engine := range []string{"claude", "codebuddy", "codebuddy-ai", "trae", "codex"} {
104
+ if warn := workspaceUnsupportedWarn(engine); warn != "" {
105
+ t.Errorf("workspaceUnsupportedWarn(%q) = %q,支持 workspace 的引擎不该提示", engine, warn)
106
+ }
107
+ }
108
+ }
109
+
110
+ func TestEnginesFlagCarriesWorkspaceCapability(t *testing.T) {
111
+ // ⚠️ 测试进程里的引擎注册表是**假的**:agent.Engines() 只在 registry 为空时才
112
+ // initEngines(),而本包测试先 Register 了假引擎 → 真实引擎不会出现。故这里按名字
113
+ // 注册几个具有代表性的假引擎(codex=flag:-C / llm=arkclaw=none / claude=cwd)。
114
+ registerFake(&wsCaptureEngine{name: "codex"})
115
+ registerFake(&wsCaptureEngine{name: "llm"})
116
+ registerFake(&wsCaptureEngine{name: "arkclaw"})
117
+ registerFake(&wsCaptureEngine{name: "claude"})
118
+ registerFake(&wsCaptureEngine{name: "dsh"})
119
+ registerFake(&wsCaptureEngine{name: "ws-cap-unlisted"})
120
+
121
+ stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
122
+ if err != nil {
123
+ t.Fatalf("--engines: %v", err)
124
+ }
125
+ var rows []struct {
126
+ Engine string `json:"engine"`
127
+ Workspace string `json:"workspace"`
128
+ Streaming bool `json:"streaming"`
129
+ }
130
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
131
+ t.Fatalf("解析 --engines 输出失败: %v (%s)", err, stdout)
132
+ }
133
+ got := map[string]string{}
134
+ for _, r := range rows {
135
+ got[r.Engine] = r.Workspace
136
+ }
137
+ for engine, want := range map[string]string{
138
+ "codex": "flag:-C",
139
+ "llm": "none",
140
+ "arkclaw": "none",
141
+ "claude": "cwd",
142
+ "dsh": "cwd", // 调用目录即 workspace 根(子进程 cwd)
143
+ "ws-cap-unlisted": "cwd", // 未识别的引擎按默认(子进程 cwd)处理
144
+ } {
145
+ if got[engine] != want {
146
+ t.Errorf("--engines 里 %s 的 workspace = %q, want %q", engine, got[engine], want)
147
+ }
148
+ }
149
+ // streaming:假引擎只实现 Complete → 一律 false;真实 codex 引擎实现了 Stream → true
150
+ streaming := map[string]bool{}
151
+ for _, r := range rows {
152
+ streaming[r.Engine] = r.Streaming
153
+ }
154
+ if streaming["ws-cap-unlisted"] {
155
+ t.Error("只实现 Complete 的假引擎 streaming 应为 false")
156
+ }
157
+ registerFake(&fakeStreamEngine{name: "stream-cap"})
158
+ stdout2, _, err := runAskCmd(t, "", "--engines", "--no-models")
159
+ if err != nil {
160
+ t.Fatalf("--engines(2): %v", err)
161
+ }
162
+ var rows2 []struct {
163
+ Engine string `json:"engine"`
164
+ Streaming bool `json:"streaming"`
165
+ }
166
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout2)), &rows2); err != nil {
167
+ t.Fatalf("解析失败: %v", err)
168
+ }
169
+ for _, r := range rows2 {
170
+ if r.Engine == "stream-cap" && !r.Streaming {
171
+ t.Error("实现了 Streamer 的引擎 streaming 应为 true")
172
+ }
173
+ }
174
+ }
175
+
176
+ // fakeStreamEngine 同时实现 Engine + Streamer(用于 streaming 能力字段断言)。
177
+ type fakeStreamEngine struct {
178
+ name string
179
+ }
180
+
181
+ func (e *fakeStreamEngine) Name() string { return e.name }
182
+ func (e *fakeStreamEngine) Detect() (bool, string) { return true, "fake://" + e.name }
183
+ func (e *fakeStreamEngine) Complete(_ context.Context, _ agent.Request) (agent.Response, error) {
184
+ return agent.Response{Text: "ok"}, nil
185
+ }
186
+ func (e *fakeStreamEngine) Stream(_ context.Context, _ agent.Request, _ func(agent.StreamEvent)) (agent.StreamResult, error) {
187
+ return agent.StreamResult{Response: agent.Response{Text: "ok"}}, nil
188
+ }