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,161 @@
|
|
|
1
|
+
package session
|
|
2
|
+
|
|
3
|
+
// session_append_test.go - 常驻会话追加入口(unix socket)的单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:ListenAppend 把 socket 路径写进记录(权限 0600)/ 往返投递 / 空内容与未知 id 报错 /
|
|
6
|
+
// 没有追加入口时的可操作提示 / socket 已死时把记录标 gone / Close 删 socket 文件。
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"os"
|
|
10
|
+
"runtime"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// startAppender 起一条带追加入口的会话,返回句柄与消息通道。
|
|
17
|
+
func startAppender(t *testing.T) (*Handle, *Appender, chan string) {
|
|
18
|
+
t.Helper()
|
|
19
|
+
isolate(t)
|
|
20
|
+
h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
21
|
+
if err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
ap, err := ListenAppend(h)
|
|
25
|
+
if err != nil {
|
|
26
|
+
if runtime.GOOS == "windows" {
|
|
27
|
+
t.Skipf("Windows 无 unix socket(预期报错): %v", err)
|
|
28
|
+
}
|
|
29
|
+
t.Fatalf("ListenAppend: %v", err)
|
|
30
|
+
}
|
|
31
|
+
t.Cleanup(ap.Close)
|
|
32
|
+
ch := make(chan string, 4)
|
|
33
|
+
go ap.Serve(ch)
|
|
34
|
+
return h, ap, ch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestListenAppendRegistersSocket(t *testing.T) {
|
|
38
|
+
h, ap, _ := startAppender(t)
|
|
39
|
+
|
|
40
|
+
if ap.Path() == "" {
|
|
41
|
+
t.Fatal("socket 路径为空")
|
|
42
|
+
}
|
|
43
|
+
if got := h.Record().Append; got != ap.Path() {
|
|
44
|
+
t.Errorf("记录里的 append = %q want %q", got, ap.Path())
|
|
45
|
+
}
|
|
46
|
+
st, err := os.Stat(ap.Path())
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("socket 文件不存在: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if perm := st.Mode().Perm(); perm != 0o600 {
|
|
51
|
+
t.Errorf("socket 权限 = %o want 600(只有同用户能追加)", perm)
|
|
52
|
+
}
|
|
53
|
+
// 记录落盘后也能读到(--append 就是从磁盘读的)
|
|
54
|
+
rec, found, err := Find(h.RunID())
|
|
55
|
+
if err != nil || !found {
|
|
56
|
+
t.Fatalf("Find: found=%v err=%v", found, err)
|
|
57
|
+
}
|
|
58
|
+
if rec.Append != ap.Path() {
|
|
59
|
+
t.Errorf("磁盘记录的 append = %q", rec.Append)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestAppendMessageDelivers(t *testing.T) {
|
|
64
|
+
h, _, ch := startAppender(t)
|
|
65
|
+
|
|
66
|
+
res, err := AppendMessage(h.RunID(), "追加:顺便把日志也整理了")
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatalf("AppendMessage: %v", err)
|
|
69
|
+
}
|
|
70
|
+
if !res.Queued {
|
|
71
|
+
t.Errorf("应 queued=true: %+v", res)
|
|
72
|
+
}
|
|
73
|
+
select {
|
|
74
|
+
case got := <-ch:
|
|
75
|
+
if got != "追加:顺便把日志也整理了" {
|
|
76
|
+
t.Errorf("收到的消息 = %q", got)
|
|
77
|
+
}
|
|
78
|
+
case <-time.After(5 * time.Second):
|
|
79
|
+
t.Fatal("消息没投递到常驻进程")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 用会话 id 追加(观物台手里通常只有 session_id)。
|
|
84
|
+
func TestAppendMessageBySessionID(t *testing.T) {
|
|
85
|
+
isolate(t)
|
|
86
|
+
h, err := Begin(BeginOptions{Engine: "claude", SessionID: "sid-append", PID: os.Getpid()})
|
|
87
|
+
if err != nil {
|
|
88
|
+
t.Fatal(err)
|
|
89
|
+
}
|
|
90
|
+
ap, err := ListenAppend(h)
|
|
91
|
+
if err != nil {
|
|
92
|
+
t.Skipf("Windows 无 unix socket: %v", err)
|
|
93
|
+
}
|
|
94
|
+
t.Cleanup(ap.Close)
|
|
95
|
+
ch := make(chan string, 2)
|
|
96
|
+
go ap.Serve(ch)
|
|
97
|
+
|
|
98
|
+
if _, err := AppendMessage("sid-append", "按会话 id 追加"); err != nil {
|
|
99
|
+
t.Fatalf("AppendMessage(session_id): %v", err)
|
|
100
|
+
}
|
|
101
|
+
select {
|
|
102
|
+
case got := <-ch:
|
|
103
|
+
if got != "按会话 id 追加" {
|
|
104
|
+
t.Errorf("收到的消息 = %q", got)
|
|
105
|
+
}
|
|
106
|
+
case <-time.After(5 * time.Second):
|
|
107
|
+
t.Fatal("没投递")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func TestAppendMessageErrors(t *testing.T) {
|
|
112
|
+
isolate(t)
|
|
113
|
+
|
|
114
|
+
if _, err := AppendMessage("whatever", " "); err == nil {
|
|
115
|
+
t.Error("空内容应报错")
|
|
116
|
+
}
|
|
117
|
+
if _, err := AppendMessage("no-such-session", "hi"); err == nil {
|
|
118
|
+
t.Error("未知 id 应报错")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 会话存在但没有追加入口(没用 --keep-alive 起)→ 提示要 --keep-alive
|
|
122
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
123
|
+
_, err := AppendMessage(h.RunID(), "hi")
|
|
124
|
+
if err == nil {
|
|
125
|
+
t.Fatal("没有追加入口应报错")
|
|
126
|
+
}
|
|
127
|
+
if !strings.Contains(err.Error(), "--keep-alive") {
|
|
128
|
+
t.Errorf("错误信息应点明要用 --keep-alive 启动: %v", err)
|
|
129
|
+
}
|
|
130
|
+
if !strings.Contains(err.Error(), "--session") {
|
|
131
|
+
t.Errorf("错误信息应给出「已结束用 --session 续接」的出路: %v", err)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 记录说 running、socket 却连不上(常驻进程崩了)→ 报错并把记录标 gone。
|
|
136
|
+
func TestAppendMessageDeadSocketMarksGone(t *testing.T) {
|
|
137
|
+
h, ap, _ := startAppender(t)
|
|
138
|
+
ap.Close() // 常驻进程「没了」
|
|
139
|
+
|
|
140
|
+
res, err := AppendMessage(h.RunID(), "hi")
|
|
141
|
+
if err == nil {
|
|
142
|
+
t.Fatal("连不上应报错")
|
|
143
|
+
}
|
|
144
|
+
if res.Record.State != StateGone {
|
|
145
|
+
t.Errorf("记录状态 = %q want %q", res.Record.State, StateGone)
|
|
146
|
+
}
|
|
147
|
+
got, _, _ := Find(h.RunID())
|
|
148
|
+
if got.State != StateGone {
|
|
149
|
+
t.Errorf("磁盘记录状态 = %q want %q", got.State, StateGone)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func TestAppenderCloseRemovesSocket(t *testing.T) {
|
|
154
|
+
_, ap, _ := startAppender(t)
|
|
155
|
+
path := ap.Path()
|
|
156
|
+
ap.Close()
|
|
157
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
158
|
+
t.Errorf("Close 应删掉 socket 文件: %v", err)
|
|
159
|
+
}
|
|
160
|
+
ap.Close() // 幂等
|
|
161
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//go:build !windows
|
|
2
|
+
|
|
3
|
+
package session
|
|
4
|
+
|
|
5
|
+
// session_kill_unix_test.go - 「真的把进程停掉」的取证(unix)。
|
|
6
|
+
//
|
|
7
|
+
// 用一个真的 `sleep 30`(Setpgid 自成进程组,与引擎 CLI 同姿势)当靶子:
|
|
8
|
+
// Stop 之后必须 Stopped=true 且进程真的没了;重复 Stop 必须幂等。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"os"
|
|
12
|
+
"os/exec"
|
|
13
|
+
"syscall"
|
|
14
|
+
"testing"
|
|
15
|
+
|
|
16
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
func TestStopKillsRealProcessGroup(t *testing.T) {
|
|
20
|
+
isolate(t)
|
|
21
|
+
|
|
22
|
+
cmd := exec.Command("sleep", "30")
|
|
23
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // 引擎 CLI 就是这么起的
|
|
24
|
+
if err := cmd.Start(); err != nil {
|
|
25
|
+
t.Fatalf("起测试进程: %v", err)
|
|
26
|
+
}
|
|
27
|
+
// 必须及时 reap:僵尸进程 kill(pid,0) 仍返回成功,会让 waitGone 白等到超时。
|
|
28
|
+
// (真实链路里 magic-agent 自己会 cmd.Wait(),等价于这里。)
|
|
29
|
+
go func() { _ = cmd.Wait() }()
|
|
30
|
+
|
|
31
|
+
pid := cmd.Process.Pid
|
|
32
|
+
if !agent.PIDAlive(pid) {
|
|
33
|
+
t.Fatal("测试进程没起来")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
h.SetPID(pid, true) // spawn 钩子在真实链路里做的事
|
|
41
|
+
|
|
42
|
+
res, err := Stop(h.RunID())
|
|
43
|
+
if err != nil {
|
|
44
|
+
t.Fatalf("Stop: %v", err)
|
|
45
|
+
}
|
|
46
|
+
if !res.Stopped {
|
|
47
|
+
t.Fatalf("应 stopped=true: %+v", res)
|
|
48
|
+
}
|
|
49
|
+
if res.Record.State != StateCancelled {
|
|
50
|
+
t.Errorf("state = %q want %q", res.Record.State, StateCancelled)
|
|
51
|
+
}
|
|
52
|
+
if agent.PIDAlive(pid) {
|
|
53
|
+
t.Errorf("pid %d 还活着 —— 没真停掉", pid)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 幂等:再停一次不报错、stopped=false
|
|
57
|
+
res2, err2 := Stop(h.RunID())
|
|
58
|
+
if err2 != nil {
|
|
59
|
+
t.Fatalf("重复停止应幂等: %v", err2)
|
|
60
|
+
}
|
|
61
|
+
if res2.Stopped {
|
|
62
|
+
t.Errorf("第二次不该报 stopped: %+v", res2)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 用 session_id 也能停(观物台/看板手里通常只有会话 id)。
|
|
67
|
+
func TestStopBySessionID(t *testing.T) {
|
|
68
|
+
isolate(t)
|
|
69
|
+
|
|
70
|
+
cmd := exec.Command("sleep", "30")
|
|
71
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
72
|
+
if err := cmd.Start(); err != nil {
|
|
73
|
+
t.Fatalf("起测试进程: %v", err)
|
|
74
|
+
}
|
|
75
|
+
go func() { _ = cmd.Wait() }()
|
|
76
|
+
|
|
77
|
+
h, _ := Begin(BeginOptions{Engine: "claude", SessionID: "sess-xyz"})
|
|
78
|
+
h.SetPID(cmd.Process.Pid, true)
|
|
79
|
+
|
|
80
|
+
res, err := Stop("sess-xyz")
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatalf("Stop(session_id): %v", err)
|
|
83
|
+
}
|
|
84
|
+
if !res.Stopped || res.Record.SessionID != "sess-xyz" {
|
|
85
|
+
t.Errorf("按 session_id 停失败: %+v", res)
|
|
86
|
+
}
|
|
87
|
+
if agent.PIDAlive(cmd.Process.Pid) {
|
|
88
|
+
t.Error("进程还在")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
package session
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
/* Handle.Mark:唯一的中途写状态入口(2026-09-29 统一会话状态词表时新增)。
|
|
9
|
+
*
|
|
10
|
+
* 它承担的是「引擎停下来等用户」这件事 —— 会话还活着,但**没在跑**,
|
|
11
|
+
* 所以界面上该显示「等审批」而不是「生成中」。三条契约:
|
|
12
|
+
* ① running ↔ waiting_approval 能来回切,并且**落盘**(不是只改内存);
|
|
13
|
+
* ② 终态不许被翻回去(一个迟到的流事件不能把已结束的会话写成还在跑);
|
|
14
|
+
* ③ 不在这一对里的值一律不写(防止它被当成万能的改状态口子)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么值得单独钉:这条路径在真机上不容易稳定复现(要引擎恰好发出一次
|
|
17
|
+
* 提问 / 待授权),而它一旦写错,错的是「这条会话结没结束」这种结论性信息。 */
|
|
18
|
+
func TestHandleMarkWaitingApproval(t *testing.T) {
|
|
19
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
20
|
+
h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid(), PromptHead: "等审批"})
|
|
21
|
+
if err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
runID := h.RunID()
|
|
25
|
+
|
|
26
|
+
// ① 进「等审批」,且必须落盘
|
|
27
|
+
h.Mark(StateWaitingApproval)
|
|
28
|
+
if got := h.Record().State; got != StateWaitingApproval {
|
|
29
|
+
t.Fatalf("内存状态 = %q, want %q", got, StateWaitingApproval)
|
|
30
|
+
}
|
|
31
|
+
rec, found, err := Find(runID)
|
|
32
|
+
if err != nil || !found {
|
|
33
|
+
t.Fatalf("读回记录失败: err=%v found=%v", err, found)
|
|
34
|
+
}
|
|
35
|
+
if rec.State != StateWaitingApproval {
|
|
36
|
+
t.Errorf("落盘状态 = %q, want %q", rec.State, StateWaitingApproval)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 用户作答(或这一轮的任何后续事件)→ 回到「正在跑」
|
|
40
|
+
h.Mark(StateRunning)
|
|
41
|
+
if got := h.Record().State; got != StateRunning {
|
|
42
|
+
t.Errorf("作答后状态 = %q, want %q", got, StateRunning)
|
|
43
|
+
}
|
|
44
|
+
rec2, _, _ := Find(runID)
|
|
45
|
+
if rec2.State != StateRunning {
|
|
46
|
+
t.Errorf("回到 running 也要落盘,实际 %q", rec2.State)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestHandleMarkDoesNotTouchTerminalState(t *testing.T) {
|
|
51
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
52
|
+
|
|
53
|
+
// 正常收尾之后,迟到的「等审批」不许把结论翻掉
|
|
54
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
55
|
+
h.Finish("sid-1", StateDone)
|
|
56
|
+
h.Mark(StateWaitingApproval)
|
|
57
|
+
if got := h.Record().State; got != StateDone {
|
|
58
|
+
t.Errorf("终态被改写了:%q, want %q", got, StateDone)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 被打断的会话同样不许被翻回 running(那会让「已经断了」变成「还在跑」)
|
|
62
|
+
h2, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
63
|
+
h2.Finish("", StateInterrupted)
|
|
64
|
+
h2.Mark(StateRunning)
|
|
65
|
+
if got := h2.Record().State; got != StateInterrupted {
|
|
66
|
+
t.Errorf("被打断的会话被翻回 running 了:%q", got)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestHandleMarkRejectsOtherStates(t *testing.T) {
|
|
71
|
+
t.Setenv(EnvDir, t.TempDir())
|
|
72
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
73
|
+
h.Mark(StateFailed) // 不在允许的一对里
|
|
74
|
+
h.Mark(StateDone)
|
|
75
|
+
h.Mark("")
|
|
76
|
+
if got := h.Record().State; got != StateRunning {
|
|
77
|
+
t.Errorf("Mark 不该把它改成 %q(只允许 running / waiting_approval)", got)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* 旧记录里的 `stopped` 读出来必须是 `cancelled`:磁盘上有别的版本写下的老值,
|
|
82
|
+
* 读的时候不归一,界面上就会冒出一个谁也不认识的词。 */
|
|
83
|
+
func TestNormalizeLegacyState(t *testing.T) {
|
|
84
|
+
if got := normalizeState("stopped"); got != StateCancelled {
|
|
85
|
+
t.Errorf("normalizeState(stopped) = %q, want %q", got, StateCancelled)
|
|
86
|
+
}
|
|
87
|
+
for _, s := range []string{StateRunning, StateWaitingApproval, StateDone, StateFailed, StateCancelled, StateInterrupted, StateGone} {
|
|
88
|
+
if got := normalizeState(s); got != s {
|
|
89
|
+
t.Errorf("normalizeState(%q) 不该改它,得到 %q", s, got)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if got := normalizeState("who_knows"); got != "who_knows" {
|
|
93
|
+
t.Errorf("不认识的词原样返回,得到 %q", got)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
package session
|
|
2
|
+
|
|
3
|
+
// session_test.go - 会话登记表单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:Begin 落盘(running)/ pid 与 session_id 回填 / 按 run_id 与 session_id 查找 /
|
|
6
|
+
// Stop(真杀进程、幂等、未知 id、已结束)/ 过期清理 / Finish 不覆盖 stopped。
|
|
7
|
+
//
|
|
8
|
+
// 全部用临时目录(t.Setenv(EnvDir)),不碰用户真实登记表。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"encoding/json"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"testing"
|
|
16
|
+
"time"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// isolate 把登记表指向临时目录。
|
|
20
|
+
//
|
|
21
|
+
// 刻意用 /tmp 下的**短**目录:unix socket 路径有 104 字节硬上限,
|
|
22
|
+
// 而 t.TempDir() 在 macOS 下是 /var/folders/.../TestXxx/001/ 这种长路径,
|
|
23
|
+
// 加上 run_id 会直接 bind 失败(我第一版就是这么挂的)。
|
|
24
|
+
func isolate(t *testing.T) string {
|
|
25
|
+
t.Helper()
|
|
26
|
+
base := "/tmp"
|
|
27
|
+
if _, err := os.Stat(base); err != nil {
|
|
28
|
+
base = ""
|
|
29
|
+
}
|
|
30
|
+
dir, err := os.MkdirTemp(base, "mas-")
|
|
31
|
+
if err != nil {
|
|
32
|
+
t.Fatalf("MkdirTemp: %v", err)
|
|
33
|
+
}
|
|
34
|
+
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
35
|
+
t.Setenv(EnvDir, dir)
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestBeginWritesRunningRecord(t *testing.T) {
|
|
40
|
+
dir := isolate(t)
|
|
41
|
+
|
|
42
|
+
h, err := Begin(BeginOptions{Engine: "claude", Model: "m1", PromptHead: "这张图什么颜色", PID: os.Getpid()})
|
|
43
|
+
if err != nil {
|
|
44
|
+
t.Fatalf("Begin: %v", err)
|
|
45
|
+
}
|
|
46
|
+
if h.RunID() == "" {
|
|
47
|
+
t.Fatal("RunID 不该为空")
|
|
48
|
+
}
|
|
49
|
+
// 文件已落盘且是合法 JSON
|
|
50
|
+
raw, err := os.ReadFile(filepath.Join(dir, h.RunID()+".json"))
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatalf("记录文件: %v", err)
|
|
53
|
+
}
|
|
54
|
+
var rec Record
|
|
55
|
+
if err := json.Unmarshal(raw, &rec); err != nil {
|
|
56
|
+
t.Fatalf("记录不是 JSON: %v", err)
|
|
57
|
+
}
|
|
58
|
+
if rec.State != StateRunning || rec.Engine != "claude" || rec.PID != os.Getpid() {
|
|
59
|
+
t.Errorf("记录内容不对: %+v", rec)
|
|
60
|
+
}
|
|
61
|
+
if rec.PromptHead != "这张图什么颜色" {
|
|
62
|
+
t.Errorf("PromptHead = %q", rec.PromptHead)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
all, err := List()
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Fatalf("List: %v", err)
|
|
68
|
+
}
|
|
69
|
+
if len(all) != 1 {
|
|
70
|
+
t.Fatalf("List 条数 = %d want 1", len(all))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestSetPIDAndFinishBackfill(t *testing.T) {
|
|
75
|
+
isolate(t)
|
|
76
|
+
|
|
77
|
+
h, err := Begin(BeginOptions{Engine: "codebuddy", PID: os.Getpid()})
|
|
78
|
+
if err != nil {
|
|
79
|
+
t.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
// 引擎子进程起来了 → 覆盖成子 pid + 进程组模式
|
|
82
|
+
h.SetPID(12345, true)
|
|
83
|
+
rec := h.Record()
|
|
84
|
+
if rec.PID != 12345 || !rec.KillGroup {
|
|
85
|
+
t.Errorf("SetPID 未生效: %+v", rec)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 新会话开始时 session_id 未知,收尾才回填
|
|
89
|
+
h.Finish("sess-abc", StateDone)
|
|
90
|
+
got, found, err := Find("sess-abc")
|
|
91
|
+
if err != nil || !found {
|
|
92
|
+
t.Fatalf("按 session_id 查找失败: found=%v err=%v", found, err)
|
|
93
|
+
}
|
|
94
|
+
if got.State != StateDone || got.PID != 12345 {
|
|
95
|
+
t.Errorf("收尾记录不对: %+v", got)
|
|
96
|
+
}
|
|
97
|
+
// run_id 也能查到同一条
|
|
98
|
+
if _, found, _ := Find(h.RunID()); !found {
|
|
99
|
+
t.Error("按 run_id 应能查到")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestFindPrefersRunning(t *testing.T) {
|
|
104
|
+
isolate(t)
|
|
105
|
+
|
|
106
|
+
// 同一 session_id 两条:先 done,后 running → 应优先 running
|
|
107
|
+
old, _ := Begin(BeginOptions{Engine: "claude", SessionID: "sid-1"})
|
|
108
|
+
old.Finish("sid-1", StateDone)
|
|
109
|
+
time.Sleep(2 * time.Millisecond) // 让 StartedAt 有区分度
|
|
110
|
+
cur, _ := Begin(BeginOptions{Engine: "claude", SessionID: "sid-1"})
|
|
111
|
+
|
|
112
|
+
got, found, err := Find("sid-1")
|
|
113
|
+
if err != nil || !found {
|
|
114
|
+
t.Fatalf("Find: found=%v err=%v", found, err)
|
|
115
|
+
}
|
|
116
|
+
if got.RunID != cur.RunID() {
|
|
117
|
+
t.Errorf("应优先 running 的那条: got %s want %s", got.RunID, cur.RunID())
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestFindUnknown(t *testing.T) {
|
|
122
|
+
isolate(t)
|
|
123
|
+
if _, found, err := Find("nope"); err != nil || found {
|
|
124
|
+
t.Errorf("未知 id 应 found=false: found=%v err=%v", found, err)
|
|
125
|
+
}
|
|
126
|
+
if _, _, err := Find(" "); err == nil {
|
|
127
|
+
t.Error("空 id 应报错")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func TestStopAlreadyFinishedIsIdempotent(t *testing.T) {
|
|
132
|
+
isolate(t)
|
|
133
|
+
|
|
134
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
135
|
+
h.Finish("sid-done", StateDone)
|
|
136
|
+
|
|
137
|
+
res, err := Stop("sid-done")
|
|
138
|
+
if err != nil {
|
|
139
|
+
t.Fatalf("已结束的会话不该报错(幂等): %v", err)
|
|
140
|
+
}
|
|
141
|
+
if res.Stopped {
|
|
142
|
+
t.Error("已结束不该报 stopped=true")
|
|
143
|
+
}
|
|
144
|
+
if !strings.Contains(res.Reason, "已结束") {
|
|
145
|
+
t.Errorf("reason = %q", res.Reason)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestStopProcessGoneMarksGone(t *testing.T) {
|
|
150
|
+
isolate(t)
|
|
151
|
+
|
|
152
|
+
// 用一个几乎不可能存在的 pid
|
|
153
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: 999999})
|
|
154
|
+
h.SetPID(999999, true)
|
|
155
|
+
|
|
156
|
+
res, err := Stop(h.RunID())
|
|
157
|
+
if err != nil {
|
|
158
|
+
t.Fatalf("Stop: %v", err)
|
|
159
|
+
}
|
|
160
|
+
if res.Stopped {
|
|
161
|
+
t.Error("进程已不在,不该报 stopped=true")
|
|
162
|
+
}
|
|
163
|
+
if res.Record.State != StateGone {
|
|
164
|
+
t.Errorf("state = %q want %q", res.Record.State, StateGone)
|
|
165
|
+
}
|
|
166
|
+
if !strings.Contains(res.Reason, "进程已不在") {
|
|
167
|
+
t.Errorf("reason = %q", res.Reason)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestStopUnknownIDIsError(t *testing.T) {
|
|
172
|
+
isolate(t)
|
|
173
|
+
if _, err := Stop("no-such-session"); err == nil {
|
|
174
|
+
t.Fatal("未知 id 应报错(多半是写错了)")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestFinishKeepsStoppedState(t *testing.T) {
|
|
179
|
+
isolate(t)
|
|
180
|
+
|
|
181
|
+
h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
|
|
182
|
+
// 模拟「被 --stop 停掉」:磁盘上写 stopped
|
|
183
|
+
h.Finish("", StateCancelled)
|
|
184
|
+
// 被杀的本进程随后以 failed 收尾 → 不能把 stopped 覆盖掉
|
|
185
|
+
h.Finish("sid-x", StateFailed)
|
|
186
|
+
|
|
187
|
+
got, found, _ := Find("sid-x")
|
|
188
|
+
if !found {
|
|
189
|
+
t.Fatal("记录丢了")
|
|
190
|
+
}
|
|
191
|
+
if got.State != StateCancelled {
|
|
192
|
+
t.Errorf("state = %q want %q(不能覆盖 cancelled)", got.State, StateCancelled)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func TestListPrunesExpiredRecords(t *testing.T) {
|
|
197
|
+
dir := isolate(t)
|
|
198
|
+
|
|
199
|
+
// 手工塞一条 25 小时前的记录(Retention = 24h)
|
|
200
|
+
old := Record{
|
|
201
|
+
RunID: "run-old",
|
|
202
|
+
Engine: "claude",
|
|
203
|
+
State: StateDone,
|
|
204
|
+
StartedAt: time.Now().Add(-25 * time.Hour),
|
|
205
|
+
UpdatedAt: time.Now().Add(-25 * time.Hour),
|
|
206
|
+
}
|
|
207
|
+
data, _ := json.Marshal(old)
|
|
208
|
+
if err := os.WriteFile(filepath.Join(dir, "run-old.json"), data, 0o600); err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
// 再来一条坏的(非法 JSON)也应被清掉
|
|
212
|
+
if err := os.WriteFile(filepath.Join(dir, "run-broken.json"), []byte("{not json"), 0o600); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
all, err := List()
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatalf("List: %v", err)
|
|
219
|
+
}
|
|
220
|
+
if len(all) != 0 {
|
|
221
|
+
t.Errorf("过期/坏记录应被清掉, got %+v", all)
|
|
222
|
+
}
|
|
223
|
+
if _, err := os.Stat(filepath.Join(dir, "run-old.json")); !os.IsNotExist(err) {
|
|
224
|
+
t.Error("过期文件应已删除")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func TestDirPrefersEnv(t *testing.T) {
|
|
229
|
+
isolate(t)
|
|
230
|
+
if got := Dir(); got == "" || !strings.Contains(got, "Test") && got == os.Getenv(EnvDir) {
|
|
231
|
+
t.Logf("Dir() = %q", got)
|
|
232
|
+
}
|
|
233
|
+
t.Setenv(EnvDir, "/tmp/x-sessions")
|
|
234
|
+
if got := Dir(); got != "/tmp/x-sessions" {
|
|
235
|
+
t.Errorf("Dir() = %q want /tmp/x-sessions", got)
|
|
236
|
+
}
|
|
237
|
+
}
|