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,263 @@
1
+ package agent
2
+
3
+ // runner_test.go - Runner 超时/重试语义测试 + output 格式测试。
4
+
5
+ import (
6
+ "bytes"
7
+ "context"
8
+ "errors"
9
+ "fmt"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+ )
14
+
15
+ // counterEngine 记录调用次数的可控假引擎。
16
+ type counterEngine struct {
17
+ name string
18
+ failures int // 前N次失败
19
+ errText string // 失败时的错误信息
20
+ calls int
21
+ sleep time.Duration // 每次调用耗时
22
+ }
23
+
24
+ func (c *counterEngine) Name() string { return c.name }
25
+ func (c *counterEngine) Detect() (bool, string) { return true, "fake" }
26
+
27
+ func (c *counterEngine) Complete(ctx context.Context, req Request) (Response, error) {
28
+ c.calls++
29
+ if c.sleep > 0 {
30
+ select {
31
+ case <-time.After(c.sleep):
32
+ case <-ctx.Done():
33
+ return Response{}, ctx.Err()
34
+ }
35
+ }
36
+ if c.calls <= c.failures {
37
+ return Response{}, errors.New(c.errText)
38
+ }
39
+ return Response{Text: fmt.Sprintf("ok#%d", c.calls), Latency: time.Millisecond}, nil
40
+ }
41
+
42
+ func TestRunnerSuccessFirstTry(t *testing.T) {
43
+ e := &counterEngine{name: "fake"}
44
+ r := &Runner{Engine: e, Retries: 3}
45
+ resp, err := r.Run(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}})
46
+ if err != nil {
47
+ t.Fatalf("Run: %v", err)
48
+ }
49
+ if e.calls != 1 || resp.Attempts != 1 {
50
+ t.Errorf("calls=%d attempts=%d, want 1/1", e.calls, resp.Attempts)
51
+ }
52
+ if resp.Text != "ok#1" {
53
+ t.Errorf("Text=%q", resp.Text)
54
+ }
55
+ }
56
+
57
+ func TestRunnerRetriesOnRetryableError(t *testing.T) {
58
+ e := &counterEngine{name: "fake", failures: 2, errText: "HTTP 429: rate limit exceeded"}
59
+ r := &Runner{Engine: e, Retries: 3, Backoff: time.Millisecond, MaxBackoff: 2 * time.Millisecond}
60
+ resp, err := r.Run(context.Background(), Request{})
61
+ if err != nil {
62
+ t.Fatalf("Run: %v", err)
63
+ }
64
+ if e.calls != 3 || resp.Attempts != 3 {
65
+ t.Errorf("calls=%d attempts=%d, want 3/3", e.calls, resp.Attempts)
66
+ }
67
+ if resp.Text != "ok#3" {
68
+ t.Errorf("Text=%q", resp.Text)
69
+ }
70
+ }
71
+
72
+ func TestRunnerFailsFastOnNonRetryable(t *testing.T) {
73
+ e := &counterEngine{name: "fake", failures: 5, errText: "invalid model: nope"}
74
+ r := &Runner{Engine: e, Retries: 3, Backoff: time.Millisecond}
75
+ _, err := r.Run(context.Background(), Request{})
76
+ if err == nil {
77
+ t.Fatal("expected error")
78
+ }
79
+ if e.calls != 1 {
80
+ t.Errorf("calls=%d, want 1 (fail fast)", e.calls)
81
+ }
82
+ if !strings.Contains(err.Error(), "non-retryable") {
83
+ t.Errorf("error should be marked non-retryable: %v", err)
84
+ }
85
+ }
86
+
87
+ func TestRunnerExhaustsRetries(t *testing.T) {
88
+ e := &counterEngine{name: "fake", failures: 99, errText: "connection refused"}
89
+ r := &Runner{Engine: e, Retries: 2, Backoff: time.Millisecond}
90
+ _, err := r.Run(context.Background(), Request{})
91
+ if err == nil {
92
+ t.Fatal("expected error")
93
+ }
94
+ if e.calls != 3 { // 1 + 2 retries
95
+ t.Errorf("calls=%d, want 3", e.calls)
96
+ }
97
+ if !strings.Contains(err.Error(), "all 3 attempts failed") {
98
+ t.Errorf("error should mention total attempts: %v", err)
99
+ }
100
+ }
101
+
102
+ func TestRunnerTimeoutPerAttempt(t *testing.T) {
103
+ e := &counterEngine{name: "fake", sleep: 200 * time.Millisecond, errText: ""}
104
+ r := &Runner{Engine: e, Timeout: 50 * time.Millisecond, Retries: 1, Backoff: time.Millisecond}
105
+ start := time.Now()
106
+ _, err := r.Run(context.Background(), Request{})
107
+ if err == nil {
108
+ t.Fatal("expected timeout error")
109
+ }
110
+ if e.calls != 2 { // 超时可重试 → 第二次也超时
111
+ t.Errorf("calls=%d, want 2", e.calls)
112
+ }
113
+ if elapsed := time.Since(start); elapsed > time.Second {
114
+ t.Errorf("took too long: %v", elapsed)
115
+ }
116
+ }
117
+
118
+ func TestRunnerCtxCancelInterrupts(t *testing.T) {
119
+ e := &counterEngine{name: "fake", failures: 99, errText: "connection refused"}
120
+ r := &Runner{Engine: e, Retries: 10, Backoff: 5 * time.Second}
121
+ ctx, cancel := context.WithCancel(context.Background())
122
+ go func() {
123
+ time.Sleep(100 * time.Millisecond)
124
+ cancel()
125
+ }()
126
+ start := time.Now()
127
+ _, err := r.Run(ctx, Request{})
128
+ if err == nil {
129
+ t.Fatal("expected cancel error")
130
+ }
131
+ if e.calls > 2 {
132
+ t.Errorf("calls=%d, cancel should interrupt early", e.calls)
133
+ }
134
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
135
+ t.Errorf("cancel took too long: %v", elapsed)
136
+ }
137
+ }
138
+
139
+ func TestIsRetryable(t *testing.T) {
140
+ retryable := []string{
141
+ "HTTP 429: too many requests",
142
+ "rate_limit exceeded",
143
+ "connection refused",
144
+ "connection reset by peer",
145
+ "unexpected EOF",
146
+ "request timeout",
147
+ "context deadline exceeded",
148
+ "signal: killed",
149
+ "HTTP 503: overloaded",
150
+ }
151
+ for _, msg := range retryable {
152
+ if !isRetryable(errors.New(msg)) {
153
+ t.Errorf("%q should be retryable", msg)
154
+ }
155
+ }
156
+ nonRetryable := []string{
157
+ "invalid model: nope",
158
+ "unknown engine",
159
+ "empty prompt",
160
+ "CLI returned empty output",
161
+ "authentication failed",
162
+ }
163
+ for _, msg := range nonRetryable {
164
+ if isRetryable(errors.New(msg)) {
165
+ t.Errorf("%q should NOT be retryable", msg)
166
+ }
167
+ }
168
+ }
169
+
170
+ // ── 输出格式 ──────────────────────────────────────────────────
171
+
172
+ func TestWriteOutputText(t *testing.T) {
173
+ var buf bytes.Buffer
174
+ resp := Response{Engine: "claude", Text: "hello\nworld", Attempts: 1}
175
+ if err := WriteOutput(&buf, FormatText, resp); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ if buf.String() != "hello\nworld\n" {
179
+ t.Errorf("text output = %q", buf.String())
180
+ }
181
+ }
182
+
183
+ func TestWriteOutputJSON(t *testing.T) {
184
+ var buf bytes.Buffer
185
+ resp := Response{
186
+ Engine: "codebuddy",
187
+ Model: "hy3",
188
+ SessionID: "s1",
189
+ Attempts: 2,
190
+ Latency: 1500 * time.Millisecond,
191
+ Text: `带"引号"的文本`,
192
+ }
193
+ if err := WriteOutput(&buf, FormatJSON, resp); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ out := buf.String()
197
+ for _, want := range []string{
198
+ `"engine":"codebuddy"`,
199
+ `"model":"hy3"`,
200
+ `"session_id":"s1"`,
201
+ `"attempts":2`,
202
+ `"latency_ms":1500`,
203
+ `"text":"带\"引号\"的文本"`,
204
+ } {
205
+ if !strings.Contains(out, want) {
206
+ t.Errorf("json output missing %s: %s", want, out)
207
+ }
208
+ }
209
+ if !strings.HasSuffix(out, "\n") {
210
+ t.Error("json output should end with newline")
211
+ }
212
+ }
213
+
214
+ func TestWriteErrorJSON(t *testing.T) {
215
+ var buf bytes.Buffer
216
+ err := fmt.Errorf("boom")
217
+ if werr := WriteError(&buf, FormatJSON, "trae", 3, err); werr != nil {
218
+ t.Fatal(werr)
219
+ }
220
+ out := buf.String()
221
+ if !strings.Contains(out, `"engine":"trae"`) || !strings.Contains(out, `"attempts":3`) || !strings.Contains(out, `"error":"boom"`) {
222
+ t.Errorf("json error envelope wrong: %s", out)
223
+ }
224
+ }
225
+
226
+ func TestParseFormat(t *testing.T) {
227
+ if _, err := ParseFormat("text"); err != nil {
228
+ t.Error("text should parse")
229
+ }
230
+ if _, err := ParseFormat("json"); err != nil {
231
+ t.Error("json should parse")
232
+ }
233
+ if _, err := ParseFormat("yaml"); err == nil {
234
+ t.Error("yaml should fail")
235
+ }
236
+ }
237
+
238
+ func TestRunnerNilEngine(t *testing.T) {
239
+ r := &Runner{}
240
+ if _, err := r.Run(context.Background(), Request{}); err == nil {
241
+ t.Fatal("expected nil engine error")
242
+ }
243
+ }
244
+
245
+ func TestRunnerDefaultTimeoutByEngine(t *testing.T) {
246
+ // 各引擎默认超时
247
+ cases := []struct {
248
+ e Engine
249
+ want time.Duration
250
+ }{
251
+ {&ClaudeEngine{}, DefaultClaudeTimeout},
252
+ {&CodeBuddyEngine{}, DefaultCodeBuddyTimeout},
253
+ {&CodeBuddyAIEngine{}, DefaultCodeBuddyTimeout},
254
+ {&TraeEngine{}, DefaultTraeTimeout},
255
+ {&counterEngine{name: "x"}, 5 * time.Minute},
256
+ }
257
+ for _, c := range cases {
258
+ r := &Runner{Engine: c.e}
259
+ if got := r.defaultTimeout(); got != c.want {
260
+ t.Errorf("%s default timeout = %v want %v", c.e.Name(), got, c.want)
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,96 @@
1
+ package agent
2
+
3
+ // spawnhook_test.go - spawn 钩子(会话登记表拿到引擎子进程 pid 的唯一途径)。
4
+ //
5
+ // 为什么关键:引擎 CLI 自成进程组(Setpgid),调用方杀 magic-agent 的进程组带不走它。
6
+ // 只有把**子进程 pid** 记进会话登记表,`magic-agent --stop <会话>` 才能停对进程。
7
+ // 这里用真引擎(ClaudeEngine / CodexEngine)+ 假 CLI 验证钩子确实收到子进程 pid。
8
+
9
+ import (
10
+ "context"
11
+ "os"
12
+ "testing"
13
+ "time"
14
+ )
15
+
16
+ func TestSpawnHookReceivesChildPID(t *testing.T) {
17
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
18
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1","model":"m"}'
19
+ `)
20
+ e := &ClaudeEngine{BinPath: cli}
21
+
22
+ type seen struct {
23
+ pid int
24
+ }
25
+ got := make(chan seen, 4)
26
+ ctx := WithSpawnHook(context.Background(), func(pid int) { got <- seen{pid} })
27
+
28
+ if _, err := e.Complete(ctx, Request{Messages: []Message{{Role: "user", Content: "hi"}}}); err != nil {
29
+ t.Fatalf("Complete: %v", err)
30
+ }
31
+
32
+ select {
33
+ case s := <-got:
34
+ if s.pid <= 0 {
35
+ t.Fatalf("钩子收到的 pid 非法: %d", s.pid)
36
+ }
37
+ if s.pid == os.Getpid() {
38
+ t.Error("钩子应给**子进程** pid,不是 magic-agent 自己")
39
+ }
40
+ case <-time.After(2 * time.Second):
41
+ t.Fatal("spawn 钩子没被调用 —— --stop 会杀错进程")
42
+ }
43
+ }
44
+
45
+ // 流式路径同样要触发钩子(观物台的对话走的是 --stream)。
46
+ func TestSpawnHookFiresOnStreamPath(t *testing.T) {
47
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
48
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-2","model":"m"}'
49
+ `)
50
+ e := &ClaudeEngine{BinPath: cli}
51
+
52
+ got := make(chan int, 4)
53
+ ctx := WithSpawnHook(context.Background(), func(pid int) { got <- pid })
54
+
55
+ if _, err := e.Stream(ctx, Request{Messages: []Message{{Role: "user", Content: "hi"}}}, nil); err != nil {
56
+ t.Fatalf("Stream: %v", err)
57
+ }
58
+ select {
59
+ case pid := <-got:
60
+ if pid <= 0 || pid == os.Getpid() {
61
+ t.Errorf("流式路径的钩子 pid 不对: %d", pid)
62
+ }
63
+ case <-time.After(2 * time.Second):
64
+ t.Fatal("流式路径没触发 spawn 钩子")
65
+ }
66
+ }
67
+
68
+ // 没挂钩子时不能崩(WithSpawnHook(nil) / 空 ctx 都要安全)。
69
+ func TestSpawnHookAbsentIsSafe(t *testing.T) {
70
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
71
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-3","model":"m"}'
72
+ `)
73
+ e := &ClaudeEngine{BinPath: cli}
74
+
75
+ // nil 回调 → 原样返回 ctx
76
+ if ctx := WithSpawnHook(context.Background(), nil); ctx == nil {
77
+ t.Fatal("WithSpawnHook(nil) 不该返回 nil ctx")
78
+ }
79
+ // 没挂钩子的普通调用照常工作
80
+ if _, err := e.Complete(context.Background(), Request{Messages: []Message{{Role: "user", Content: "hi"}}}); err != nil {
81
+ t.Fatalf("无钩子调用失败: %v", err)
82
+ }
83
+ // 直接调 notifySpawn 也不能崩
84
+ notifySpawn(context.Background(), 12345)
85
+ notifySpawn(nil, 1)
86
+ }
87
+
88
+ // PIDAlive:当前进程活着、明显不存在的 pid 不活着。
89
+ func TestPIDAlive(t *testing.T) {
90
+ if !PIDAlive(os.Getpid()) {
91
+ t.Error("自身 pid 应判定为活着")
92
+ }
93
+ if PIDAlive(0) || PIDAlive(-1) {
94
+ t.Error("非法 pid 应判定为不活着")
95
+ }
96
+ }