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,166 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"reflect"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// sinkSamples 覆盖全部事件类型:用来钉住「--stream 与 --events 的字段集只差 v / seq」。
|
|
14
|
+
func sinkSamples() []agent.StreamEvent {
|
|
15
|
+
return []agent.StreamEvent{
|
|
16
|
+
{Kind: agent.KindText, Text: "正文"},
|
|
17
|
+
{Kind: agent.KindThinking, Text: "思考"},
|
|
18
|
+
{Kind: agent.KindToolUse, Text: `{"command":"ls"}`, Name: "Bash", ID: "toolu_1"},
|
|
19
|
+
{Kind: agent.KindToolResult, Text: "输出", ID: "toolu_1"},
|
|
20
|
+
{Kind: agent.KindTurnEnd, Text: "本轮正文", SessionID: "sess_abc"},
|
|
21
|
+
{Kind: agent.KindAsk, Text: "要选一个"},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func decodeEventLine(t *testing.T, s string) map[string]any {
|
|
26
|
+
t.Helper()
|
|
27
|
+
var m map[string]any
|
|
28
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(s)), &m); err != nil {
|
|
29
|
+
t.Fatalf("解析事件行失败: %v(%q)", err, s)
|
|
30
|
+
}
|
|
31
|
+
return m
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TestEventSinkShapeParity 老形状(--stream)与新形状(--stream --events)的字段集
|
|
35
|
+
// 必须只差 v / seq,取值逐项相同。
|
|
36
|
+
//
|
|
37
|
+
// 这条是本文件里最重要的断言:两个形状共用 streamEventPayload(),但 envelope 那一步
|
|
38
|
+
// 做了「marshal → 加字段 → marshal」的往返,字段丢失或改名只会在新形状里发生。
|
|
39
|
+
func TestEventSinkShapeParity(t *testing.T) {
|
|
40
|
+
for _, ev := range sinkSamples() {
|
|
41
|
+
var plain, enriched bytes.Buffer
|
|
42
|
+
if err := newEventSink(&plain, "claude", false).stream(ev); err != nil {
|
|
43
|
+
t.Fatalf("%s: 老形状 %v", ev.Kind, err)
|
|
44
|
+
}
|
|
45
|
+
if err := newEventSink(&enriched, "claude", true).stream(ev); err != nil {
|
|
46
|
+
t.Fatalf("%s: 新形状 %v", ev.Kind, err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
a := decodeEventLine(t, plain.String())
|
|
50
|
+
b := decodeEventLine(t, enriched.String())
|
|
51
|
+
|
|
52
|
+
if _, ok := b["v"]; !ok {
|
|
53
|
+
t.Errorf("%s: --events 形态缺 v 字段: %v", ev.Kind, b)
|
|
54
|
+
}
|
|
55
|
+
if _, ok := b["seq"]; !ok {
|
|
56
|
+
t.Errorf("%s: --events 形态缺 seq 字段: %v", ev.Kind, b)
|
|
57
|
+
}
|
|
58
|
+
delete(b, "v")
|
|
59
|
+
delete(b, "seq")
|
|
60
|
+
if !reflect.DeepEqual(a, b) {
|
|
61
|
+
t.Errorf("%s: 两条路径不一致\n 老形状: %v\n 新形状: %v", ev.Kind, a, b)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// TestEventSinkSeqMonotonic 行号从 1 起、逐行 +1;每行都带契约版本。
|
|
67
|
+
func TestEventSinkSeqMonotonic(t *testing.T) {
|
|
68
|
+
var buf bytes.Buffer
|
|
69
|
+
s := newEventSink(&buf, "claude", true)
|
|
70
|
+
for _, ev := range sinkSamples() {
|
|
71
|
+
if err := s.stream(ev); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
|
76
|
+
if len(lines) != len(sinkSamples()) {
|
|
77
|
+
t.Fatalf("行数 = %d want %d", len(lines), len(sinkSamples()))
|
|
78
|
+
}
|
|
79
|
+
for i, ln := range lines {
|
|
80
|
+
m := decodeEventLine(t, ln)
|
|
81
|
+
if got := int(m["seq"].(float64)); got != i+1 {
|
|
82
|
+
t.Errorf("第 %d 行 seq = %d want %d", i, got, i+1)
|
|
83
|
+
}
|
|
84
|
+
if got := int(m["v"].(float64)); got != agent.ContractVersion {
|
|
85
|
+
t.Errorf("第 %d 行 v = %d want %d", i, got, agent.ContractVersion)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// TestEventSinkSessionSticky 会话 id 一旦拿到,后续每行都带(客户端据此绑定会话)。
|
|
91
|
+
func TestEventSinkSessionSticky(t *testing.T) {
|
|
92
|
+
var buf bytes.Buffer
|
|
93
|
+
s := newEventSink(&buf, "claude", true)
|
|
94
|
+
if err := s.stream(agent.StreamEvent{Kind: agent.KindTurnEnd, Text: "done", SessionID: "sess_x"}); err != nil {
|
|
95
|
+
t.Fatal(err)
|
|
96
|
+
}
|
|
97
|
+
if err := s.stream(agent.StreamEvent{Kind: agent.KindText, Text: "下一轮"}); err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
|
101
|
+
if m := decodeEventLine(t, lines[1]); m["session_id"] != "sess_x" {
|
|
102
|
+
t.Errorf("turn_end 之后的正文应带 session_id=sess_x,实际 %v", m["session_id"])
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// TestEventSinkNewEventsGated ready 与控制应答只在 --events 下输出 ——
|
|
107
|
+
// 老消费者不认识新事件类型,往老形状里注入它们是静默的形状变更。
|
|
108
|
+
func TestEventSinkNewEventsGated(t *testing.T) {
|
|
109
|
+
var off bytes.Buffer
|
|
110
|
+
offSink := newEventSink(&off, "claude", false)
|
|
111
|
+
if err := offSink.ready("hy3"); err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
if err := offSink.notice("pong", nil); err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
if off.Len() != 0 {
|
|
118
|
+
t.Errorf("未开 --events 时不该输出 ready / 控制应答,实际 %q", off.String())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
var on bytes.Buffer
|
|
122
|
+
onSink := newEventSink(&on, "claude", true)
|
|
123
|
+
if err := onSink.ready("hy3"); err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
if err := onSink.notice("pong", nil); err != nil {
|
|
127
|
+
t.Fatal(err)
|
|
128
|
+
}
|
|
129
|
+
lines := strings.Split(strings.TrimSpace(on.String()), "\n")
|
|
130
|
+
if m := decodeEventLine(t, lines[0]); m["type"] != "ready" || m["engine"] != "claude" || m["model"] != "hy3" {
|
|
131
|
+
t.Errorf("ready 行不对: %v", m)
|
|
132
|
+
}
|
|
133
|
+
m := decodeEventLine(t, lines[1])
|
|
134
|
+
if m["type"] != "pong" {
|
|
135
|
+
t.Errorf("pong 行不对: %v", m)
|
|
136
|
+
}
|
|
137
|
+
if _, ok := m["v"]; !ok {
|
|
138
|
+
t.Error("控制应答也应带 v")
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// TestStreamFlagsRequireStream 给了 --events / --control 却没给 --stream 要报错。
|
|
143
|
+
// 本项目的立场:静默忽略是 bug 的温床(超时被静默忽略那类事故)。
|
|
144
|
+
func TestStreamFlagsRequireStream(t *testing.T) {
|
|
145
|
+
for _, flag := range []string{"--events", "--control"} {
|
|
146
|
+
_, _, err := runAskCmd(t, "", flag, "hi")
|
|
147
|
+
if err == nil {
|
|
148
|
+
t.Errorf("%s 不带 --stream 应当报错", flag)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if !strings.Contains(err.Error(), "--stream") {
|
|
152
|
+
t.Errorf("%s 的报错应提到 --stream,实际: %v", flag, err)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// TestControlConflictsWithStdinPrompt --control 占用 stdin,与「从 stdin 读提示词」互斥。
|
|
158
|
+
func TestControlConflictsWithStdinPrompt(t *testing.T) {
|
|
159
|
+
_, _, err := runAskCmd(t, "提示词", "--stream", "--control", "-f", "-")
|
|
160
|
+
if err == nil {
|
|
161
|
+
t.Fatal("--control 与 -f - 应当冲突并报错")
|
|
162
|
+
}
|
|
163
|
+
if !strings.Contains(err.Error(), "stdin") {
|
|
164
|
+
t.Errorf("报错应解释 stdin 冲突,实际: %v", err)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// keepalive_test.go - CLI 侧「常驻会话 + 追加消息」的测试。
|
|
4
|
+
//
|
|
5
|
+
// 用户需求(2026-09-18):「按路线 2 实现,先支持 claude 和 codebuddy 的追加功能」。
|
|
6
|
+
//
|
|
7
|
+
// 覆盖:--keep-alive 的前置校验(必须 --stream / 引擎必须支持 / --idle 必须为正)/
|
|
8
|
+
// --append 的参数校验与错误路径 / 端到端(起常驻会话 → --append 投递 → 空闲收工)。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"context"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"strings"
|
|
17
|
+
"sync"
|
|
18
|
+
"testing"
|
|
19
|
+
"time"
|
|
20
|
+
|
|
21
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
22
|
+
"github.com/darren/magic-agent/internal/session"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// kaEngine 假引擎:模拟 claude/codebuddy 的常驻会话 —— 消费 req.Append,
|
|
26
|
+
// 每收到一条就发一个 turn_end(真实引擎在 result 行时发)。
|
|
27
|
+
type kaEngine struct {
|
|
28
|
+
name string
|
|
29
|
+
mu sync.Mutex
|
|
30
|
+
got []string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (k *kaEngine) Name() string { return k.name }
|
|
34
|
+
func (k *kaEngine) Detect() (bool, string) { return true, "fake://" + k.name }
|
|
35
|
+
func (k *kaEngine) Complete(context.Context, agent.Request) (agent.Response, error) {
|
|
36
|
+
return agent.Response{Text: "complete", Model: "fake-model"}, nil
|
|
37
|
+
}
|
|
38
|
+
func (k *kaEngine) Stream(_ context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
|
|
39
|
+
if req.Append == nil {
|
|
40
|
+
return agent.StreamResult{}, errors.New("kaEngine 需要 Append 通道")
|
|
41
|
+
}
|
|
42
|
+
for msg := range req.Append {
|
|
43
|
+
k.mu.Lock()
|
|
44
|
+
k.got = append(k.got, msg)
|
|
45
|
+
k.mu.Unlock()
|
|
46
|
+
if onEvent != nil {
|
|
47
|
+
onEvent(agent.StreamEvent{Kind: agent.KindTurnEnd, Text: "ok"})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return agent.StreamResult{
|
|
51
|
+
Response: agent.Response{Engine: k.name, Text: "done", SessionID: "sid-ka", Model: "fake-model"},
|
|
52
|
+
}, nil
|
|
53
|
+
}
|
|
54
|
+
func (k *kaEngine) received() []string {
|
|
55
|
+
k.mu.Lock()
|
|
56
|
+
defer k.mu.Unlock()
|
|
57
|
+
return append([]string(nil), k.got...)
|
|
58
|
+
}
|
|
59
|
+
func (k *kaEngine) reset() {
|
|
60
|
+
k.mu.Lock()
|
|
61
|
+
k.got = nil
|
|
62
|
+
k.mu.Unlock()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ⚠️ 整包共用一个 "codebuddy" 假引擎:`agent.Lookup` 取注册表里**首个**同名匹配,
|
|
66
|
+
// 每个用例各注册一个的话,后注册的永远不被使用(我第一版就是这么挂的:
|
|
67
|
+
// 追加投递成功、断言却打在另一个实例上)。用前 reset 即可。
|
|
68
|
+
var (
|
|
69
|
+
kaRegisterOnce sync.Once
|
|
70
|
+
kaShared = &kaEngine{name: "codebuddy"}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
func kaEngineForTest(t *testing.T) *kaEngine {
|
|
74
|
+
t.Helper()
|
|
75
|
+
kaRegisterOnce.Do(func() { registerFake(kaShared) })
|
|
76
|
+
kaShared.reset()
|
|
77
|
+
return kaShared
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// shortSessionsDir 给测试一个**短**的登记表目录(unix socket 路径有 104 字节上限)。
|
|
81
|
+
func shortSessionsDir(t *testing.T) string {
|
|
82
|
+
t.Helper()
|
|
83
|
+
dir, err := os.MkdirTemp("/tmp", "mas-cli-")
|
|
84
|
+
if err != nil {
|
|
85
|
+
t.Fatalf("MkdirTemp: %v", err)
|
|
86
|
+
}
|
|
87
|
+
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
88
|
+
return dir
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── 前置校验 ─────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
func TestKeepAliveRequiresStream(t *testing.T) {
|
|
94
|
+
kaEngineForTest(t)
|
|
95
|
+
_, _, err := runAskCmd(t, "", "-e", "codebuddy", "--keep-alive", "-p", "hi")
|
|
96
|
+
if err == nil {
|
|
97
|
+
t.Fatal("--keep-alive 不配 --stream 应报错")
|
|
98
|
+
}
|
|
99
|
+
if _, ok := err.(*usageError); !ok {
|
|
100
|
+
t.Errorf("应是 usageError(exit 2), got %T: %v", err, err)
|
|
101
|
+
}
|
|
102
|
+
if !strings.Contains(err.Error(), "--stream") {
|
|
103
|
+
t.Errorf("错误信息应点明要 --stream: %v", err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 不支持的引擎:直接测能力分支(不走注册表 —— cli 包测试里的引擎是假的,
|
|
108
|
+
// 且同名只认首个注册,用 -e trae 跑真流程会先撞上别的假引擎的「不支持流式」)。
|
|
109
|
+
func TestKeepAliveUnsupportedEngine(t *testing.T) {
|
|
110
|
+
t.Setenv(session.EnvDir, shortSessionsDir(t))
|
|
111
|
+
cmd := NewRootCommand()
|
|
112
|
+
opts := newAskOptions()
|
|
113
|
+
opts.keepAlive, opts.stream, opts.idle = true, true, time.Second
|
|
114
|
+
|
|
115
|
+
_, err := startKeepAlive(cmd, opts, "trae", nil)
|
|
116
|
+
if err == nil {
|
|
117
|
+
t.Fatal("不支持的引擎应报错")
|
|
118
|
+
}
|
|
119
|
+
if _, ok := err.(*usageError); !ok {
|
|
120
|
+
t.Errorf("应是 usageError(exit 2), got %T: %v", err, err)
|
|
121
|
+
}
|
|
122
|
+
if !strings.Contains(err.Error(), "claude") || !strings.Contains(err.Error(), "codebuddy") {
|
|
123
|
+
t.Errorf("错误信息应说明当前支持 claude/codebuddy: %v", err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func TestKeepAliveRejectsNegativeIdle(t *testing.T) {
|
|
128
|
+
kaEngineForTest(t)
|
|
129
|
+
_, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--idle", "-1s", "-p", "hi")
|
|
130
|
+
if err == nil {
|
|
131
|
+
t.Fatal("--idle 负值应报错")
|
|
132
|
+
}
|
|
133
|
+
if _, ok := err.(*usageError); !ok {
|
|
134
|
+
t.Errorf("应是 usageError, got %T: %v", err, err)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 默认就是常驻:claude/codebuddy 的 --stream 调用不传 --keep-alive 也会开出追加入口。
|
|
139
|
+
func TestKeepAliveIsDefaultForStreaming(t *testing.T) {
|
|
140
|
+
dir := shortSessionsDir(t)
|
|
141
|
+
t.Setenv(session.EnvDir, dir)
|
|
142
|
+
eng := kaEngineForTest(t)
|
|
143
|
+
|
|
144
|
+
done := make(chan error, 1)
|
|
145
|
+
go func() {
|
|
146
|
+
_, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--idle", "1s", "-p", "首轮")
|
|
147
|
+
done <- err
|
|
148
|
+
}()
|
|
149
|
+
|
|
150
|
+
var runID string
|
|
151
|
+
deadline := time.Now().Add(10 * time.Second)
|
|
152
|
+
for time.Now().Before(deadline) && runID == "" {
|
|
153
|
+
for _, rec := range mustListSessions(t, dir) {
|
|
154
|
+
if rec.Append != "" {
|
|
155
|
+
runID = rec.RunID
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
time.Sleep(50 * time.Millisecond)
|
|
159
|
+
}
|
|
160
|
+
if runID == "" {
|
|
161
|
+
t.Fatal("默认(不传 --keep-alive)也该开出追加入口")
|
|
162
|
+
}
|
|
163
|
+
// 追加可用
|
|
164
|
+
if _, _, err := runAskCmd(t, "", "--append", runID, "-p", "默认模式下的追加"); err != nil {
|
|
165
|
+
t.Fatalf("--append: %v", err)
|
|
166
|
+
}
|
|
167
|
+
select {
|
|
168
|
+
case err := <-done:
|
|
169
|
+
if err != nil {
|
|
170
|
+
t.Fatalf("收尾报错: %v", err)
|
|
171
|
+
}
|
|
172
|
+
case <-time.After(20 * time.Second):
|
|
173
|
+
t.Fatal("没收工")
|
|
174
|
+
}
|
|
175
|
+
if got := eng.received(); len(got) != 1 || got[0] != "默认模式下的追加" {
|
|
176
|
+
t.Errorf("引擎收到的追加 = %v", got)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --keep-alive=false 关掉常驻:不建 socket,turn 结束即退出(老行为)。
|
|
181
|
+
func TestKeepAliveCanBeDisabled(t *testing.T) {
|
|
182
|
+
dir := shortSessionsDir(t)
|
|
183
|
+
t.Setenv(session.EnvDir, dir)
|
|
184
|
+
registerFake(&streamingEngine{name: "fake-ka-off", text: "ok"})
|
|
185
|
+
|
|
186
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-ka-off", "--stream", "--keep-alive=false", "hi"); err != nil {
|
|
187
|
+
t.Fatalf("ask: %v", err)
|
|
188
|
+
}
|
|
189
|
+
recs := mustListSessions(t, dir)
|
|
190
|
+
if len(recs) != 1 {
|
|
191
|
+
t.Fatalf("登记表条数 = %d", len(recs))
|
|
192
|
+
}
|
|
193
|
+
if recs[0].Append != "" {
|
|
194
|
+
t.Errorf("--keep-alive=false 不该开追加入口: %q", recs[0].Append)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 非流式调用默认不常驻(否则普通提问会挂住等追加)。
|
|
199
|
+
func TestKeepAliveNotAppliedToNonStream(t *testing.T) {
|
|
200
|
+
dir := shortSessionsDir(t)
|
|
201
|
+
t.Setenv(session.EnvDir, dir)
|
|
202
|
+
registerFake(&stringEngine{name: "fake-ka-nostream", text: "ok"})
|
|
203
|
+
|
|
204
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-ka-nostream", "hi"); err != nil {
|
|
205
|
+
t.Fatalf("ask: %v", err)
|
|
206
|
+
}
|
|
207
|
+
recs := mustListSessions(t, dir)
|
|
208
|
+
if len(recs) != 1 || recs[0].Append != "" {
|
|
209
|
+
t.Errorf("非流式调用不该开追加入口: %+v", recs)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── --append 错误路径 ────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
func TestAppendRequiresIDAndText(t *testing.T) {
|
|
216
|
+
t.Setenv(session.EnvDir, shortSessionsDir(t))
|
|
217
|
+
|
|
218
|
+
_, _, err := runAskCmd(t, "", "--append", "")
|
|
219
|
+
if err == nil {
|
|
220
|
+
t.Fatal("空 id 应报错")
|
|
221
|
+
}
|
|
222
|
+
if _, ok := err.(*usageError); !ok {
|
|
223
|
+
t.Errorf("空 id 应是 usageError, got %T: %v", err, err)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_, _, err = runAskCmd(t, "", "--append", "some-id")
|
|
227
|
+
if err == nil {
|
|
228
|
+
t.Fatal("没有内容应报错")
|
|
229
|
+
}
|
|
230
|
+
if _, ok := err.(*usageError); !ok {
|
|
231
|
+
t.Errorf("空内容应是 usageError, got %T: %v", err, err)
|
|
232
|
+
}
|
|
233
|
+
if !strings.Contains(err.Error(), "-p") {
|
|
234
|
+
t.Errorf("错误信息应说明怎么给内容: %v", err)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func TestAppendUnknownSession(t *testing.T) {
|
|
239
|
+
t.Setenv(session.EnvDir, shortSessionsDir(t))
|
|
240
|
+
|
|
241
|
+
_, _, err := runAskCmd(t, "", "--append", "no-such-session", "-p", "追加")
|
|
242
|
+
if err == nil {
|
|
243
|
+
t.Fatal("未知 id 应报错")
|
|
244
|
+
}
|
|
245
|
+
if _, ok := err.(*usageError); ok {
|
|
246
|
+
t.Errorf("未知 id 不是参数错(不该 exit 2): %v", err)
|
|
247
|
+
}
|
|
248
|
+
if !strings.Contains(err.Error(), "no such session") {
|
|
249
|
+
t.Errorf("错误信息应点明找不到: %v", err)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 普通(非常驻)会话不能追加 —— 错误要指出用 --keep-alive 启动、已结束用 --session 续接。
|
|
254
|
+
func TestAppendToNonKeepAliveSession(t *testing.T) {
|
|
255
|
+
t.Setenv(session.EnvDir, shortSessionsDir(t))
|
|
256
|
+
registerFake(&stringEngine{name: "fake-ka-none", text: "ok"})
|
|
257
|
+
|
|
258
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-ka-none", "普通调用"); err != nil {
|
|
259
|
+
t.Fatalf("ask: %v", err)
|
|
260
|
+
}
|
|
261
|
+
rows := parseSessions(t, mustSessions(t))
|
|
262
|
+
if len(rows) != 1 {
|
|
263
|
+
t.Fatalf("登记表条数 = %d", len(rows))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_, _, err := runAskCmd(t, "", "--append", rows[0].RunID, "-p", "追加")
|
|
267
|
+
if err == nil {
|
|
268
|
+
t.Fatal("没有追加入口应报错")
|
|
269
|
+
}
|
|
270
|
+
if !strings.Contains(err.Error(), "--keep-alive") {
|
|
271
|
+
t.Errorf("错误信息应点明要用 --keep-alive 启动: %v", err)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── 端到端:常驻会话 + 追加 + 空闲收工 ────────────────────────
|
|
276
|
+
|
|
277
|
+
func TestKeepAliveAppendEndToEnd(t *testing.T) {
|
|
278
|
+
dir := shortSessionsDir(t)
|
|
279
|
+
t.Setenv(session.EnvDir, dir)
|
|
280
|
+
|
|
281
|
+
eng := kaEngineForTest(t)
|
|
282
|
+
|
|
283
|
+
// ① 起常驻会话(后台跑,空闲 1s 自动收工)
|
|
284
|
+
done := make(chan error, 1)
|
|
285
|
+
go func() {
|
|
286
|
+
_, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--keep-alive",
|
|
287
|
+
"--idle", "1s", "-p", "首轮任务")
|
|
288
|
+
done <- err
|
|
289
|
+
}()
|
|
290
|
+
|
|
291
|
+
// ② 等登记表里出现追加入口
|
|
292
|
+
var runID string
|
|
293
|
+
deadline := time.Now().Add(10 * time.Second)
|
|
294
|
+
for time.Now().Before(deadline) {
|
|
295
|
+
for _, rec := range mustListSessions(t, dir) {
|
|
296
|
+
if rec.Append != "" && rec.State == session.StateRunning {
|
|
297
|
+
runID = rec.RunID
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if runID != "" {
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
time.Sleep(50 * time.Millisecond)
|
|
304
|
+
}
|
|
305
|
+
if runID == "" {
|
|
306
|
+
t.Fatal("常驻会话没在登记表里开出追加入口")
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ③ 追加一条(走真实的 unix socket 通道)
|
|
310
|
+
stdout, _, err := runAskCmd(t, "", "--append", runID, "-p", "追加:顺便跑一下测试")
|
|
311
|
+
if err != nil {
|
|
312
|
+
t.Fatalf("--append: %v", err)
|
|
313
|
+
}
|
|
314
|
+
var out struct {
|
|
315
|
+
Type string `json:"type"`
|
|
316
|
+
Queued bool `json:"queued"`
|
|
317
|
+
State string `json:"state"`
|
|
318
|
+
}
|
|
319
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &out); err != nil {
|
|
320
|
+
t.Fatalf("--append 输出不是 JSON: %v (%s)", err, stdout)
|
|
321
|
+
}
|
|
322
|
+
if out.Type != "append" || !out.Queued {
|
|
323
|
+
t.Errorf("--append 结果不对: %+v", out)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ④ 空闲 1s 后应自动收工(不需要 --stop)
|
|
327
|
+
select {
|
|
328
|
+
case err := <-done:
|
|
329
|
+
if err != nil {
|
|
330
|
+
t.Fatalf("常驻会话收尾报错: %v", err)
|
|
331
|
+
}
|
|
332
|
+
case <-time.After(20 * time.Second):
|
|
333
|
+
t.Fatal("空闲超时后常驻会话没收工")
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ⑤ 引擎确实收到了追加内容
|
|
337
|
+
if got := eng.received(); len(got) != 1 || got[0] != "追加:顺便跑一下测试" {
|
|
338
|
+
t.Errorf("引擎收到的追加 = %v", got)
|
|
339
|
+
}
|
|
340
|
+
// ⑥ 会话记录收尾为 done,socket 文件被清掉
|
|
341
|
+
recs := mustListSessions(t, dir)
|
|
342
|
+
if len(recs) != 1 {
|
|
343
|
+
t.Fatalf("登记表条数 = %d", len(recs))
|
|
344
|
+
}
|
|
345
|
+
if recs[0].State != session.StateDone {
|
|
346
|
+
t.Errorf("会话状态 = %q want done", recs[0].State)
|
|
347
|
+
}
|
|
348
|
+
if _, err := os.Stat(filepath.Join(dir, runID+".sock")); !os.IsNotExist(err) {
|
|
349
|
+
t.Errorf("收工后 socket 文件应被删掉: %v", err)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// mustListSessions 直接读登记表(比 --sessions 的 JSON 更直接,且能看到 append 字段)。
|
|
354
|
+
func mustListSessions(t *testing.T, dir string) []session.Record {
|
|
355
|
+
t.Helper()
|
|
356
|
+
t.Setenv(session.EnvDir, dir)
|
|
357
|
+
recs, err := session.List()
|
|
358
|
+
if err != nil {
|
|
359
|
+
t.Fatalf("List: %v", err)
|
|
360
|
+
}
|
|
361
|
+
return recs
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --engines 要暴露「能不能追加」这个能力(观物台据此决定是否提供追加入口)。
|
|
365
|
+
func TestEnginesFlagCarriesAppendCapability(t *testing.T) {
|
|
366
|
+
kaEngineForTest(t) // codebuddy(支持)
|
|
367
|
+
registerFake(&stringEngine{name: "trae"}) // 不支持
|
|
368
|
+
registerFake(&stringEngine{name: "claude"})
|
|
369
|
+
|
|
370
|
+
stdout, _, err := runAskCmd(t, "", "--engines", "--no-models")
|
|
371
|
+
if err != nil {
|
|
372
|
+
t.Fatalf("--engines: %v", err)
|
|
373
|
+
}
|
|
374
|
+
var rows []struct {
|
|
375
|
+
Engine string `json:"engine"`
|
|
376
|
+
Append bool `json:"append"`
|
|
377
|
+
}
|
|
378
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &rows); err != nil {
|
|
379
|
+
t.Fatalf("解析 --engines 输出失败: %v (%s)", err, stdout)
|
|
380
|
+
}
|
|
381
|
+
got := map[string]bool{}
|
|
382
|
+
for _, r := range rows {
|
|
383
|
+
got[r.Engine] = r.Append
|
|
384
|
+
}
|
|
385
|
+
if !got["codebuddy"] {
|
|
386
|
+
t.Error("codebuddy 的 append 应为 true")
|
|
387
|
+
}
|
|
388
|
+
if got["trae"] {
|
|
389
|
+
t.Error("trae 的 append 应为 false")
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// login.go - `--login <engine>`:拉起某引擎自己的交互式登录会话。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它:codebuddy / codebuddy-ai 跑的是**同一个 CLI 的两个账号**,
|
|
6
|
+
// 登录时必须带上各自的账号环境(配置目录 + ACC_PRODUCT_CONFIG_V3 里的
|
|
7
|
+
// authentication.id),否则登录结果会写进**另一个账号**的票据文件。
|
|
8
|
+
// 这几个变量只有引擎自己知道(见 internal/agent/login.go 的 LoginRunner),
|
|
9
|
+
// 所以这里只负责 exec 与 stdio 直通,不重复拼环境。
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"errors"
|
|
13
|
+
"fmt"
|
|
14
|
+
"os"
|
|
15
|
+
"os/exec"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
19
|
+
"github.com/spf13/cobra"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// runLogin 处理 `--login <engine>`。
|
|
23
|
+
func runLogin(cmd *cobra.Command, opts *askOptions) error {
|
|
24
|
+
name := strings.TrimSpace(opts.login)
|
|
25
|
+
if name == "" {
|
|
26
|
+
return &usageError{fmt.Errorf("--login 需要引擎名:magic-agent --login <engine>(可用:%s)", loginCapableEngines())}
|
|
27
|
+
}
|
|
28
|
+
eng := agent.Lookup(name)
|
|
29
|
+
if eng == nil {
|
|
30
|
+
return &usageError{fmt.Errorf("未知引擎 %q;可用:%s", name, loginCapableEngines())}
|
|
31
|
+
}
|
|
32
|
+
lr, ok := eng.(agent.LoginRunner)
|
|
33
|
+
if !ok {
|
|
34
|
+
return &usageError{fmt.Errorf(
|
|
35
|
+
"引擎 %s 不需要 --login:它的登录态由自己的 CLI 管理,直接运行该 CLI 登录即可", eng.Name())}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
bin, args, extraEnv, err := lr.LoginCommand()
|
|
39
|
+
if err != nil {
|
|
40
|
+
return err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// stdio 直通:交互式界面必须拿到真的 TTY。
|
|
44
|
+
c := exec.Command(bin, args...)
|
|
45
|
+
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
46
|
+
c.Env = agent.ChildEnvWith(extraEnv)
|
|
47
|
+
if wd, werr := os.Getwd(); werr == nil {
|
|
48
|
+
c.Dir = wd
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 提示走 stderr:stdout 留给子进程的交互界面,不被污染。
|
|
52
|
+
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
53
|
+
"magic-agent: 已按 %s 的账号环境启动 %s\n"+
|
|
54
|
+
" 在里面执行 /login 完成登录(退出后本命令结束)\n", eng.Name(), bin)
|
|
55
|
+
|
|
56
|
+
if runErr := c.Run(); runErr != nil {
|
|
57
|
+
var ee *exec.ExitError
|
|
58
|
+
if errors.As(runErr, &ee) {
|
|
59
|
+
return fmt.Errorf("%s 会话退出码 %d", eng.Name(), ee.ExitCode())
|
|
60
|
+
}
|
|
61
|
+
return fmt.Errorf("启动 %s 失败: %w", bin, runErr)
|
|
62
|
+
}
|
|
63
|
+
return nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// loginCapableEngines 列出实现了 LoginRunner 的引擎名(报错提示用)。
|
|
67
|
+
func loginCapableEngines() string {
|
|
68
|
+
var names []string
|
|
69
|
+
for _, e := range agent.Engines() {
|
|
70
|
+
if _, ok := e.(agent.LoginRunner); ok {
|
|
71
|
+
names = append(names, e.Name())
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if len(names) == 0 {
|
|
75
|
+
return "(无)"
|
|
76
|
+
}
|
|
77
|
+
return strings.Join(names, " / ")
|
|
78
|
+
}
|