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,325 @@
1
+ package cli
2
+
3
+ // sessionlog.go - 把流式会话的每条事件落盘到 <dir>/<session_id>.jsonl。
4
+ //
5
+ // 目的:把会话从「一次性消费流」升级为「可重读」。
6
+ // 之前 magic-agent 的会话登记表(~/.magic-agent/sessions/run-*.json)只记元数据
7
+ // (提示词首行 / 状态 / 时间),不存消息内容 —— 移动端没法看历史。这次落地之后:
8
+ // · 流式消费者同时收到 NDJSON(实时)与 jsonl 文件(持久化)
9
+ // · 移动端进入会话详情时先拉历史 → 已经有内容可渲染;再开 SSE 接续
10
+ //
11
+ // 三条不能违反的约束:
12
+ // 1. **绝不能断流**:写盘失败、慢、磁盘满 —— 只打一行 warning,不抛错、不阻塞 onEvent。
13
+ // 否则一次 EIO 就把流式变成报错(结果链就是 magic-agent 退 = 插件 SSE 收尾 = 用户界面卡死)。
14
+ // 2. **最多一次重发**:每条事件写一次;不重试,不重读。不重试是因为重写会污染 seq 序列
15
+ // (历史接口靠 seq 游标),且消息内容重复展示 = 体验差。
16
+ // 3. **session_id 未知前也能暂存**:不同引擎在流的不同位置暴露 session_id(init / assistant / result),
17
+ // 而最早的 thinking / tool_use 事件可能先到。开文件必须 lazy —— 拿到 id 再开。
18
+ //
19
+ // 落盘动作走 goroutine + buffered channel:调用方 (onEvent) 只做「塞进 channel 即返回」,
20
+ // IO 慢的副作用不会传到流路径。
21
+
22
+ import (
23
+ "bufio"
24
+ "encoding/json"
25
+ "errors"
26
+ "fmt"
27
+ "os"
28
+ "path/filepath"
29
+ "sync"
30
+ "sync/atomic"
31
+ "time"
32
+
33
+ "github.com/darren/magic-agent/internal/agent"
34
+ "github.com/darren/magic-agent/internal/session"
35
+ )
36
+
37
+ // SessionLogDirOverride 给测试用:override 默认的 ~/.magic-agent/sessions/。
38
+ var SessionLogDirOverride string
39
+
40
+ // sessionLogDir 返回本会话日志的目录路径:
41
+ // - 显式 override(SessionLogDirOverride)优先;
42
+ // - 默认是 ~/.magic-agent/sessions(与 session 登记表同一目录)。
43
+ func sessionLogDir() (string, error) {
44
+ if SessionLogDirOverride != "" {
45
+ return SessionLogDirOverride, nil
46
+ }
47
+ home, err := os.UserHomeDir()
48
+ if err != nil {
49
+ return "", fmt.Errorf("找不到家目录:%w", err)
50
+ }
51
+ return filepath.Join(home, ".magic-agent", "sessions"), nil
52
+ }
53
+
54
+ // sessionEvent 是写盘格式:每条一行 JSON、自带 seq。
55
+ // seq 用来做"上一页"(cursor)—— anywhere 那条 timeline 也是这么干的。
56
+ //
57
+ // Kind 取自两处,合并成一份词表:
58
+ // - 引擎事件(internal/agent/stream.go 的 StreamEventKind):thinking / text /
59
+ // tool_use / tool_result / ask / turn_end;
60
+ // - core 自己补的:user(用户提问,见 ask.go runStreamAsk —— 引擎事件里没有它,
61
+ // 但历史缺了它就只剩 agent 单方面说话)、error(失败兜底)。
62
+ //
63
+ // 注意:Thinking 文本可能很大(claude 长思考),Text 可能有工具调用摘要(tool_use),
64
+ // 都直接 JSON 序列化。文件按行追加,断电最坏丢最后一行(不是大问题,流仍在跑)。
65
+ type sessionEvent struct {
66
+ Seq uint64 `json:"seq"`
67
+ At string `json:"at"`
68
+ Kind string `json:"kind"`
69
+ Text string `json:"text,omitempty"`
70
+ Name string `json:"name,omitempty"`
71
+ ID string `json:"id,omitempty"`
72
+ // ToolKind 工具调用的**子形态**(command / file_change / web_search / mcp /
73
+ // agent_call / input_request / permission / tool_call),只有 tool_use 事件有,
74
+ // 与 SSE 流同源(见 agent/toolkind.go 与 events.go 的 toolKindOfEvent)。
75
+ // 有了它,历史回放和实时渲染才会画出同一种卡 —— 否则回放那侧只能按名字猜。
76
+ ToolKind string `json:"tool_kind,omitempty"`
77
+ SessionID string `json:"session_id,omitempty"`
78
+ Ask *agent.AskRequest `json:"ask,omitempty"`
79
+ }
80
+
81
+ // sessionWriter 每条流式调用一个实例;开着就一直 append 到
82
+ // `<dir>/<session_id>.jsonl`,session_id 还没出来时事件先**丢进 buffer**等 id。
83
+ //
84
+ // ⚠️ **不重发**:buffer 等 id 时最多暂存 ~64 条事件;满了就丢(按 round-robin),
85
+ // 并 emit 一行 warning。我们接受这个损失 —— 历史只对「点进去看历史」这件事有用,
86
+ // 极少数早期事件丢了用户察觉不到(call 号还在 SSE 流里);但「突然不写盘」绝对不行。
87
+ type sessionWriter struct {
88
+ dir string
89
+ runID uint64
90
+ stop chan struct{}
91
+ done chan struct{}
92
+ seq atomic.Uint64
93
+ in chan sessionEvent
94
+ mu sync.Mutex
95
+ pending []sessionEvent
96
+ file *os.File
97
+ w *bufio.Writer
98
+ filePath string
99
+ overflow atomic.Uint64 // 被 ring-buffer 丢弃的事件计数(诊断用)
100
+
101
+ // sessionID 跨 goroutine 共享:CLI 的 OnSessionID 写入(流路径),
102
+ // sw.write 读(写盘 goroutine)。atomic.Pointer[string] 保证可见性。
103
+ sessionID atomic.Pointer[string]
104
+ }
105
+
106
+ // newSessionWriter 创建并启动一个 sessionWriter(仅包内测试用,单测不依赖 onSessionID)。
107
+ func newSessionWriter(dir string, runID uint64) *sessionWriter {
108
+ sw := &sessionWriter{
109
+ dir: dir,
110
+ runID: runID,
111
+ stop: make(chan struct{}),
112
+ done: make(chan struct{}),
113
+ in: make(chan sessionEvent, 128),
114
+ }
115
+ initID := ""
116
+ sw.sessionID.Store(&initID)
117
+ go sw.loop()
118
+ return sw
119
+ }
120
+
121
+ // setSessionID 由 CLI 端 OnSessionID 调用 —— 流里见到 session_id 就推给 writer。
122
+ func (sw *sessionWriter) setSessionID(id string) {
123
+ s := id
124
+ sw.sessionID.Store(&s)
125
+ }
126
+
127
+ // currentSessionID 在 sw.write 里读;不可与 OnSessionID 配对交换字段(race)。
128
+ func (sw *sessionWriter) currentSessionID() string {
129
+ return *sw.sessionID.Load()
130
+ }
131
+
132
+ // newSessionWriterAuto 解析目录 + 起 writer。
133
+ // 解析失败:返回 nil + error,让调用方决定「不写盘继续」还是「中断流」。
134
+ func newSessionWriterAuto(dirFn func() (string, error)) (*sessionWriter, error) {
135
+ dir, err := dirFn()
136
+ if err != nil {
137
+ return nil, err
138
+ }
139
+ if err := os.MkdirAll(dir, 0o700); err != nil {
140
+ return nil, err
141
+ }
142
+ var counter atomic.Uint64
143
+ runID := counter.Add(1)
144
+ sw := newSessionWriter(dir, runID)
145
+ return sw, nil
146
+ }
147
+
148
+ // push 是 onEvent 的最后一行:把一条事件塞进 channel;通道满了就丢 + 计数。
149
+ // 返回 nil;不出错(**绝不能断流**)。
150
+ func (sw *sessionWriter) push(ev sessionEvent) {
151
+ select {
152
+ case sw.in <- ev:
153
+ default:
154
+ // 满了:写盘跟不上。先按 ring-buffer 的思路存到 pending;如果 pending 也满就丢。
155
+ sw.mu.Lock()
156
+ if len(sw.pending) < 64 {
157
+ sw.pending = append(sw.pending, ev)
158
+ } else {
159
+ sw.overflow.Add(1)
160
+ }
161
+ sw.mu.Unlock()
162
+ }
163
+ }
164
+
165
+ // close 让 writer 把 pending + channel 全部 flush 后退出。
166
+ // 多次调用安全;后续的 push 仍然能被丢弃(不应发生:调用方应该在 cmd.Wait 之后才结束)。
167
+ func (sw *sessionWriter) close() {
168
+ select {
169
+ case <-sw.stop:
170
+ // already closed
171
+ default:
172
+ close(sw.stop)
173
+ }
174
+ <-sw.done
175
+ }
176
+
177
+ // loop 是单 goroutine 的 IO 循环 —— 拉事件、拿会话 id、写盘、刷文件。
178
+ //
179
+ // 顺序的简化:把「拿 session_id」和「打开文件」放在事件处理里。第一条事件没 id
180
+ // → 进 pending;有 id 之后**只**开一次文件并把 pending 也写进去。之后每条事件
181
+ // 直接落盘,不再有"没 id"的问题。
182
+ func (sw *sessionWriter) loop() {
183
+ defer close(sw.done)
184
+ defer func() {
185
+ if sw.w != nil {
186
+ _ = sw.w.Flush()
187
+ _ = sw.file.Close()
188
+ }
189
+ }()
190
+ for {
191
+ select {
192
+ case <-sw.stop:
193
+ // drain:把 in 里剩的都写出去(直到通道空)。
194
+ for {
195
+ select {
196
+ case ev := <-sw.in:
197
+ sw.write(ev)
198
+ default:
199
+ return
200
+ }
201
+ }
202
+ case ev := <-sw.in:
203
+ sw.write(ev)
204
+ }
205
+ }
206
+ }
207
+
208
+ // write 是单条事件的落盘。id 还没出现就暂存;id 出现后开文件并把暂存的也写进去。
209
+ func (sw *sessionWriter) write(ev sessionEvent) {
210
+ // 1. 拿到当前 session_id(来自 accumulator;流早期可能为空)。
211
+ if ev.SessionID == "" {
212
+ ev.SessionID = sw.currentSessionID()
213
+ }
214
+ if ev.SessionID == "" {
215
+ // 还没 id:进 pending,等后续事件触发「打开文件」的动作。
216
+ sw.mu.Lock()
217
+ if len(sw.pending) < 64 {
218
+ sw.pending = append(sw.pending, ev)
219
+ } else {
220
+ sw.overflow.Add(1)
221
+ }
222
+ sw.mu.Unlock()
223
+ return
224
+ }
225
+ // 2. 还没开文件:开 + 刷 pending。文件路径 = dir/<session_id>.jsonl。
226
+ if sw.file == nil {
227
+ if err := sw.openFile(ev.SessionID); err != nil {
228
+ // 开不开得出来(磁盘满 / 权限):本条丢弃,**不抛错**。下一条再来还试。
229
+ fmt.Fprintf(os.Stderr, "magic-agent: 持久化会话失败(%s):%v(流继续)\n", ev.SessionID, err)
230
+ return
231
+ }
232
+ // pending 也写进去(首次不丢早期事件)。
233
+ sw.mu.Lock()
234
+ pend := sw.pending
235
+ sw.pending = nil
236
+ sw.mu.Unlock()
237
+ for _, pev := range pend {
238
+ sw.appendLine(pev)
239
+ }
240
+ }
241
+ sw.appendLine(ev)
242
+ }
243
+
244
+ // openFile 在 <dir> 下创建/追加 <session_id>.jsonl。权限 0600(与 session 登记表一致)。
245
+ //
246
+ // ⚠️ 用 append 模式 + 0600:不截断已有文件(续接同一会话不会重复第一条事件),
247
+ // 权限只有同用户能读 —— 跨用户读自己用 read-only 调用就好了。
248
+ func (sw *sessionWriter) openFile(id string) error {
249
+ sw.filePath = filepath.Join(sw.dir, id+".jsonl")
250
+ f, err := os.OpenFile(sw.filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ sw.file = f
255
+ sw.w = bufio.NewWriterSize(f, 32*1024) // 32KB 缓冲;写完一行 Flush,由 timer 兜底
256
+ return nil
257
+ }
258
+
259
+ // appendLine 写一行 JSON。错(写满 / 设备断开)只 log、不 panic、不影响流路径。
260
+ func (sw *sessionWriter) appendLine(ev sessionEvent) {
261
+ ev.Seq = sw.seq.Add(1)
262
+ if ev.At == "" {
263
+ ev.At = time.Now().UTC().Format(time.RFC3339Nano)
264
+ }
265
+ if ev.SessionID == "" {
266
+ ev.SessionID = sw.currentSessionID()
267
+ }
268
+ b, err := json.Marshal(ev)
269
+ if err != nil {
270
+ fmt.Fprintf(os.Stderr, "magic-agent: 会话事件序列化失败:%v(流继续)\n", err)
271
+ return
272
+ }
273
+ b = append(b, '\n')
274
+ if _, err := sw.w.Write(b); err != nil {
275
+ fmt.Fprintf(os.Stderr, "magic-agent: 写会话失败:%v(流继续)\n", err)
276
+ return
277
+ }
278
+ // 不每行都 Flush:bufio 攒一批,close 时一次性刷。代价:断电最坏丢一批;
279
+ // 流路径完全不受影响。disk 也减少 90%+ 的 fsync。
280
+ }
281
+
282
+ // sessionEventFromJSON 把 jsonl 的一行解析成 sessionEvent。
283
+ // 与 session.ReadSessionLog 配对使用:CLI 知道自己的 schema,调用方把"怎么解析"
284
+ // 当成回调传过去,避免 session 包反向依赖 cli。
285
+ func sessionEventFromJSON(line []byte) (sessionEvent, error) {
286
+ var ev sessionEvent
287
+ if err := json.Unmarshal(line, &ev); err != nil {
288
+ return sessionEvent{}, err
289
+ }
290
+ return ev, nil
291
+ }
292
+
293
+ // ReadSessionLog 读整个 jsonl 到 []sessionEvent,调用方拿去做 UI 渲染或 diff。
294
+ //
295
+ // 顺序:seq 升序;空文件返回 nil。损坏行跳过(warn 一行)—— 历史不能 100% 保证,
296
+ // 但绝大多数行都是单行 JSON + 换行结尾,单条坏掉的概率极低。
297
+ func ReadSessionLog(dir, sessionID string) ([]sessionEvent, error) {
298
+ if dir == "" {
299
+ var err error
300
+ dir, err = sessionLogDir()
301
+ if err != nil {
302
+ return nil, err
303
+ }
304
+ }
305
+ return session.ReadSessionLog(dir, sessionID, sessionEventFromJSON)
306
+ }
307
+
308
+ // ReadSessionLogAfter 增量读:只取 seq > after 的事件,并回磁盘上的最大 seq。
309
+ //
310
+ // 存在的理由与全量读不同:断线重连 / 长会话续看时不必重传整段历史(差一个数量级)。
311
+ // dir 传空 = 用默认历史目录(与 ReadSessionLog 同一口径)。
312
+ func ReadSessionLogAfter(dir, sessionID string, after uint64) ([]sessionEvent, uint64, error) {
313
+ if dir == "" {
314
+ var err error
315
+ dir, err = sessionLogDir()
316
+ if err != nil {
317
+ return nil, 0, err
318
+ }
319
+ }
320
+ return session.ReadSessionLogAfter(dir, sessionID, after,
321
+ func(e sessionEvent) uint64 { return e.Seq }, sessionEventFromJSON)
322
+ }
323
+
324
+ // errClosed 表示 writer 已关。push 收到这个就吞掉 —— 不该发生,但保险。
325
+ var errClosed = errors.New("session writer closed")
@@ -0,0 +1,204 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ // `--session-log`(会话历史的读取命令)的端到端验证。
12
+ //
13
+ // 为什么值得这些断言:这条命令是 magic-agent **不跑服务** 时,调用方(掌天瓶等)
14
+ // 拿会话历史的**唯一**通道。它坏掉的表现是「界面历史整段空白」,而那属于静默失败 ——
15
+ // 所以下面既钉住正常路径,也钉住几个容易做错的边界:
16
+ // - 追平(after == lastSeq)必须是**正常空增量**,不能报 snapshotRequired;
17
+ // - 游标超前(after > lastSeq)才要求重拉全量 —— 判据是**严格大于**;
18
+ // - `--after` 单用必须**报错**,不能静默忽略(本项目反复踩过静默忽略的坑);
19
+ // - 没有历史的会话是**空数组 + 成功**,不是错误。
20
+
21
+ // writeSessionLog 造一份会话历史文件,返回日志目录。
22
+ func writeSessionLog(t *testing.T, sessionID, body string) string {
23
+ t.Helper()
24
+ dir := t.TempDir()
25
+ if err := os.WriteFile(filepath.Join(dir, sessionID+".jsonl"), []byte(body), 0o644); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ prev := SessionLogDirOverride
29
+ t.Cleanup(func() { SessionLogDirOverride = prev })
30
+ SessionLogDirOverride = dir
31
+ return dir
32
+ }
33
+
34
+ const sampleLog = `{"seq":1,"at":"2026-01-01T00:00:00Z","kind":"user","text":"帮我改登录页","session_id":"s1"}
35
+ {"seq":2,"at":"2026-01-01T00:00:01Z","kind":"thinking","text":"先看目录","session_id":"s1"}
36
+ {"seq":3,"at":"2026-01-01T00:00:02Z","kind":"text","text":"改好了","session_id":"s1"}
37
+ `
38
+
39
+ // logEnvelope 与插件 /desk/session/{id}/messages 的同形状:{session, events}。
40
+ type logEnvelope struct {
41
+ Session string `json:"session"`
42
+ Events []json.RawMessage `json:"events"`
43
+ }
44
+
45
+ // logDelta 与插件 /desk/session/{id}/events 的同形状。
46
+ type logDelta struct {
47
+ Session string `json:"session"`
48
+ Events []json.RawMessage `json:"events"`
49
+ Count int `json:"count"`
50
+ After uint64 `json:"after"`
51
+ LastSeq uint64 `json:"lastSeq"`
52
+ SnapshotRequired bool `json:"snapshotRequired"`
53
+ }
54
+
55
+ // TestSessionLogFull 全量读:形状与插件一致、顺序按 seq。
56
+ func TestSessionLogFull(t *testing.T) {
57
+ writeSessionLog(t, "s1", sampleLog)
58
+
59
+ out, _, err := runAskCmd(t, "", "--session-log", "s1")
60
+ if err != nil {
61
+ t.Fatalf("读历史不该报错:%v", err)
62
+ }
63
+ var got logEnvelope
64
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
65
+ t.Fatalf("输出不是 JSON:%v\n%s", err, out)
66
+ }
67
+ if got.Session != "s1" {
68
+ t.Errorf("session = %q,想要 s1", got.Session)
69
+ }
70
+ if len(got.Events) != 3 {
71
+ t.Fatalf("事件数 = %d,想要 3", len(got.Events))
72
+ }
73
+ // 顺序必须是 seq 升序(历史接口靠 seq 做游标,乱了续读就废了)
74
+ var first struct {
75
+ Seq uint64 `json:"seq"`
76
+ Kind string `json:"kind"`
77
+ Text string `json:"text"`
78
+ }
79
+ if err := json.Unmarshal(got.Events[0], &first); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if first.Seq != 1 || first.Kind != "user" || first.Text != "帮我改登录页" {
83
+ t.Errorf("首条不对:%+v", first)
84
+ }
85
+ }
86
+
87
+ // TestSessionLogAfter 增量:只回 seq > after 的部分。
88
+ func TestSessionLogAfter(t *testing.T) {
89
+ writeSessionLog(t, "s1", sampleLog)
90
+
91
+ out, _, err := runAskCmd(t, "", "--session-log", "s1", "--after", "2")
92
+ if err != nil {
93
+ t.Fatalf("不该报错:%v", err)
94
+ }
95
+ var got logDelta
96
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
97
+ t.Fatalf("输出不是 JSON:%v\n%s", err, out)
98
+ }
99
+ if got.Count != 1 || len(got.Events) != 1 {
100
+ t.Fatalf("after=2 应只剩 1 条,实际 %d", len(got.Events))
101
+ }
102
+ if got.After != 2 || got.LastSeq != 3 {
103
+ t.Errorf("after/lastSeq = %d/%d,想要 2/3", got.After, got.LastSeq)
104
+ }
105
+ if got.SnapshotRequired {
106
+ t.Error("还有新事件时不该要求重拉全量")
107
+ }
108
+ }
109
+
110
+ // TestSessionLogAfterCaughtUp 追平(after == lastSeq):正常空增量,**不**要求重拉。
111
+ //
112
+ // ⚠️ 这条最容易写错:判据若写成 `>=`,每次追平都会让客户端白拉一遍全量历史。
113
+ func TestSessionLogAfterCaughtUp(t *testing.T) {
114
+ writeSessionLog(t, "s1", sampleLog)
115
+
116
+ out, _, err := runAskCmd(t, "", "--session-log", "s1", "--after", "3")
117
+ if err != nil {
118
+ t.Fatalf("不该报错:%v", err)
119
+ }
120
+ var got logDelta
121
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ if got.Count != 0 {
125
+ t.Errorf("追平时应是空增量,实际 %d 条", got.Count)
126
+ }
127
+ if got.LastSeq != 3 {
128
+ t.Errorf("lastSeq = %d,想要 3", got.LastSeq)
129
+ }
130
+ if got.SnapshotRequired {
131
+ t.Error("追平(after == lastSeq)不是异常,不该要求重拉全量")
132
+ }
133
+ }
134
+
135
+ // TestSessionLogAfterAhead 游标超前磁盘:增量不可信,要求重拉全量。
136
+ func TestSessionLogAfterAhead(t *testing.T) {
137
+ writeSessionLog(t, "s1", sampleLog)
138
+
139
+ out, _, err := runAskCmd(t, "", "--session-log", "s1", "--after", "9")
140
+ if err != nil {
141
+ t.Fatalf("不该报错:%v", err)
142
+ }
143
+ var got logDelta
144
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if !got.SnapshotRequired {
148
+ t.Error("游标比磁盘还新时必须要求重拉全量,否则客户端会一直以为没有新事件")
149
+ }
150
+ if got.Count != 0 {
151
+ t.Errorf("超前时应无事件,实际 %d 条", got.Count)
152
+ }
153
+ }
154
+
155
+ // TestSessionLogMissing 没有历史的会话:空数组 + 成功(空历史不是错)。
156
+ func TestSessionLogMissing(t *testing.T) {
157
+ writeSessionLog(t, "s1", sampleLog)
158
+
159
+ out, _, err := runAskCmd(t, "", "--session-log", "no-such-session")
160
+ if err != nil {
161
+ t.Fatalf("不存在的会话不该报错:%v", err)
162
+ }
163
+ var got logEnvelope
164
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
165
+ t.Fatal(err)
166
+ }
167
+ if len(got.Events) != 0 {
168
+ t.Errorf("应为空数组,实际 %d 条", len(got.Events))
169
+ }
170
+ // 必须是 [],不能是 null —— 调用方写 `for (ev of events)` 时 null 会直接抛。
171
+ if !strings.Contains(out, `"events":[]`) {
172
+ t.Errorf("空历史要输出 [],实际:%s", out)
173
+ }
174
+ }
175
+
176
+ // TestSessionLogNeedsID 空 id 要报错(走参数校验,掉进提问分支的话会报 empty prompt)。
177
+ func TestSessionLogNeedsID(t *testing.T) {
178
+ writeSessionLog(t, "s1", sampleLog)
179
+
180
+ _, _, err := runAskCmd(t, "", "--session-log", "")
181
+ if err == nil {
182
+ t.Fatal("空 id 应该报错")
183
+ }
184
+ if !strings.Contains(err.Error(), "需要会话 id") {
185
+ t.Errorf("错误信息该说清要 id,实际:%v", err)
186
+ }
187
+ }
188
+
189
+ // TestAfterAloneIsRejected `--after` 不给 `--session-log` 时必须报错。
190
+ //
191
+ // 为什么单列一条:本项目对「传了却被静默忽略」的开关有过多次事故记录
192
+ // (README 的「-t 600s 被静默忽略」)。`--after` 单独出现是典型的误用,
193
+ // 静默忽略会让调用方以为增量读生效了、其实拿到的是全量。
194
+ func TestAfterAloneIsRejected(t *testing.T) {
195
+ writeSessionLog(t, "s1", sampleLog)
196
+
197
+ _, _, err := runAskCmd(t, "", "--after", "5")
198
+ if err == nil {
199
+ t.Fatal("--after 单用应该报错,不能被静默忽略")
200
+ }
201
+ if !strings.Contains(err.Error(), "--session-log") {
202
+ t.Errorf("错误信息该点出要配合 --session-log,实际:%v", err)
203
+ }
204
+ }