magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -5,6 +5,7 @@ import (
5
5
  "os"
6
6
  "path/filepath"
7
7
  "testing"
8
+ "time"
8
9
  )
9
10
 
10
11
  // 会话日志的增量读(断线续读)。这一组钉住三件最容易写错的事:
@@ -155,3 +156,412 @@ func TestReadSessionLogAfterEmptyDirRejected(t *testing.T) {
155
156
  t.Fatal("空 dir 期望报错,实得 nil")
156
157
  }
157
158
  }
159
+
160
+ /* ── lean 读:解码前摘 snapshot(2026-10-20)──
161
+ snapshot 是「该 item 到此刻的累积整段」,每条增量都带一份 ⇒ 长会话二次方膨胀
162
+ (实测一条 9.4 万条 thinking 的会话 283MB 快照 / 整份 338MB)。回放侧只用增量 text
163
+ 自己拼,没人读它 ⇒ 在**解码前**整段摘掉,避免为它分配 + 反转义。 */
164
+
165
+ // leanSnapEvent 是解析 lean 结果用的类型:snapshot 必须解析出来为空。
166
+ type leanSnapEvent struct {
167
+ Seq uint64 `json:"seq"`
168
+ Text string `json:"text"`
169
+ Snapshot string `json:"snapshot"`
170
+ Error string `json:"error"`
171
+ }
172
+
173
+ func parseLeanSnapEvent(line []byte) (leanSnapEvent, error) {
174
+ var ev leanSnapEvent
175
+ err := json.Unmarshal(line, &ev)
176
+ return ev, err
177
+ }
178
+
179
+ // TestStripSnapshotField 钉住字节级摘除的各形态。
180
+ //
181
+ // 最要紧的是「不误伤」与「不提前收尾」:snapshot 值里有转义引号 / 花括号时,
182
+ // 朴素地找下一个 `"` 或 `}` 会把后半行切坏(JSON 变成坏的 → 整条事件静默消失)。
183
+ func TestStripSnapshotField(t *testing.T) {
184
+ cases := []struct{ name, in, want string }{
185
+ {
186
+ name: "末尾字段(thinking 的实际形态)",
187
+ in: `{"seq":2,"text":"The","snapshot":"The"}`,
188
+ want: `{"seq":2,"text":"The"}`,
189
+ },
190
+ {
191
+ name: "后面还有字段(error / attachments)",
192
+ in: `{"seq":3,"text":"a","snapshot":"cum","error":"boom"}`,
193
+ want: `{"seq":3,"text":"a","error":"boom"}`,
194
+ },
195
+ {
196
+ name: "值里有转义引号与反斜杠",
197
+ in: `{"seq":4,"text":"a","snapshot":"say \"hi\" \\ done","error":"x"}`,
198
+ want: `{"seq":4,"text":"a","error":"x"}`,
199
+ },
200
+ {
201
+ name: "值不是字符串(结构快照)也能按括号配对跳过",
202
+ in: `{"seq":5,"a":1,"snapshot":[{"x":"}"}],"b":2}`,
203
+ want: `{"seq":5,"a":1,"b":2}`,
204
+ },
205
+ {
206
+ name: "text 里出现转义后的同名字样不误伤",
207
+ in: `{"seq":6,"text":"x,\"snapshot\":1","snapshot":"cum"}`,
208
+ want: `{"seq":6,"text":"x,\"snapshot\":1"}`,
209
+ },
210
+ {
211
+ name: "没有该字段原样返回",
212
+ in: `{"seq":7,"text":"a"}`,
213
+ want: `{"seq":7,"text":"a"}`,
214
+ },
215
+ {
216
+ name: "坏值(未闭合)原样返回,交给 unmarshal 报错跳过",
217
+ in: `{"seq":8,"snapshot":"never closed`,
218
+ want: `{"seq":8,"snapshot":"never closed`,
219
+ },
220
+ {
221
+ name: "末尾字段 + 值里有转义引号与花括号",
222
+ in: `{"seq":9,"text":"t","snapshot":"say \"hi\" {x} \\ done"}`,
223
+ want: `{"seq":9,"text":"t"}`,
224
+ },
225
+ {
226
+ name: "末尾字段 + 值以偶数个反斜杠收尾",
227
+ in: `{"seq":10,"text":"t","snapshot":"ends \\\\"}`,
228
+ want: `{"seq":10,"text":"t"}`,
229
+ },
230
+ }
231
+ for _, c := range cases {
232
+ if got := string(stripSnapshotField([]byte(c.in))); got != c.want {
233
+ t.Errorf("%s:\n 实得 %s\n 期望 %s", c.name, got, c.want)
234
+ }
235
+ }
236
+ }
237
+
238
+ // TestEndOfJSONString 钉住字符串结束位置的转义判据:反斜杠个数为偶数才是真结尾。
239
+ func TestEndOfJSONString(t *testing.T) {
240
+ cases := []struct {
241
+ in string
242
+ want int
243
+ }{
244
+ {`"abc"`, 5},
245
+ {`"a\"b"`, 6},
246
+ {`"a\\"`, 5},
247
+ {`"a\\\"b"`, 8},
248
+ {`""`, 2},
249
+ {`"never`, -1},
250
+ }
251
+ for _, c := range cases {
252
+ if got := endOfJSONString([]byte(c.in), 0); got != c.want {
253
+ t.Errorf("%s:实得 %d,期望 %d", c.in, got, c.want)
254
+ }
255
+ }
256
+ }
257
+
258
+ // TestReadSessionLogLeanDropsSnapshot 全量 / 增量两条 lean 读都要:
259
+ // - snapshot 解析出来为空(输出与「先读后删」一致)
260
+ // - 其它字段逐字保留
261
+ // - maxSeq 仍覆盖整个文件(合并/裁剪不影响游标判据)
262
+ func TestReadSessionLogLeanDropsSnapshot(t *testing.T) {
263
+ dir := writeLog(t, "s",
264
+ `{"seq":1,"text":"a","snapshot":"a"}`,
265
+ `{"seq":2,"text":"b","snapshot":"ab","error":"x"}`,
266
+ `{"seq":3,"text":"c"}`,
267
+ )
268
+
269
+ evs, err := ReadSessionLogLean(dir, "s", parseLeanSnapEvent)
270
+ if err != nil {
271
+ t.Fatalf("lean 读失败:%v", err)
272
+ }
273
+ if len(evs) != 3 {
274
+ t.Fatalf("期望 3 条,实得 %d", len(evs))
275
+ }
276
+ for _, ev := range evs {
277
+ if ev.Snapshot != "" {
278
+ t.Fatalf("seq=%d 的 snapshot 应被摘掉,实得 %q", ev.Seq, ev.Snapshot)
279
+ }
280
+ }
281
+ if evs[1].Text != "b" || evs[1].Error != "x" {
282
+ t.Fatalf("snapshot 之后的字段被切坏了:%+v", evs[1])
283
+ }
284
+
285
+ // 非 lean 仍要能读到 snapshot(老行为不变)。
286
+ full, err := ReadSessionLog(dir, "s", parseLeanSnapEvent)
287
+ if err != nil {
288
+ t.Fatalf("全量读失败:%v", err)
289
+ }
290
+ if full[1].Snapshot != "ab" {
291
+ t.Fatalf("非 lean 应保留 snapshot,实得 %q", full[1].Snapshot)
292
+ }
293
+
294
+ // 增量 + lean:after=1 → 只回 2、3,但 maxSeq 仍是整份文件的 3。
295
+ inc, maxSeq, err := ReadSessionLogAfterLean(dir, "s", 1,
296
+ func(e leanSnapEvent) uint64 { return e.Seq }, parseLeanSnapEvent)
297
+ if err != nil {
298
+ t.Fatalf("lean 增量读失败:%v", err)
299
+ }
300
+ if len(inc) != 2 || inc[0].Seq != 2 || inc[1].Seq != 3 {
301
+ t.Fatalf("期望只回 seq=2,3,实得 %+v", inc)
302
+ }
303
+ if maxSeq != 3 {
304
+ t.Fatalf("maxSeq 应覆盖整个文件(期望 3,实得 %d)", maxSeq)
305
+ }
306
+ }
307
+
308
+ /* ── 尾部游标续读(2026-10-20)──
309
+ 客户端(尤其移动端)每 3s 用 `/events?after=N` 轮询运行中会话;日志是纯追加的,
310
+ 但 seq 只能解析出来 ⇒ 原先每次从文件头重扫全文,354MB 的会话单次 2~3.4s。
311
+ tailMark 记住「seq=N 之后的字节从哪开始」,续读只碰尾部。 */
312
+
313
+ func appendLog(t *testing.T, path string, body string) {
314
+ t.Helper()
315
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
316
+ if err != nil {
317
+ t.Fatal(err)
318
+ }
319
+ if _, err := f.WriteString(body); err != nil {
320
+ f.Close()
321
+ t.Fatal(err)
322
+ }
323
+ if err := f.Close(); err != nil {
324
+ t.Fatal(err)
325
+ }
326
+ }
327
+
328
+ func mustMark(t *testing.T, path string, seq uint64) tailMark {
329
+ t.Helper()
330
+ m, ok := loadTailMark(path, seq)
331
+ if !ok {
332
+ t.Fatalf("期望 %s 有 seq=%d 的尾部游标,实得没有", path, seq)
333
+ }
334
+ return m
335
+ }
336
+
337
+ func TestReadSessionLogAfterTailMark(t *testing.T) {
338
+ dir := writeLog(t, "s", `{"seq":1,"text":"a"}`, `{"seq":2,"text":"b"}`)
339
+ path := filepath.Join(dir, "s.jsonl")
340
+ seqOf := func(e seqEvent) uint64 { return e.Seq }
341
+
342
+ // 首次全量:读完应建 mark(seq=2,offset=文件末尾)。
343
+ if _, maxSeq, err := ReadSessionLogAfter(dir, "s", 0, seqOf, parseSeqEvent); err != nil || maxSeq != 2 {
344
+ t.Fatalf("全量读失败:maxSeq=%d err=%v", maxSeq, err)
345
+ }
346
+ st, _ := os.Stat(path)
347
+ m := mustMark(t, path, 2)
348
+ if m.offset != st.Size() {
349
+ t.Fatalf("全量读后游标 offset 期望 %d,实得 %+v", st.Size(), m)
350
+ }
351
+
352
+ // 追加两条 → after=2 走尾部续读,只回新增的。
353
+ appendLog(t, path, `{"seq":3,"text":"c"}`+"\n"+`{"seq":4,"text":"d"}`+"\n")
354
+ evs, maxSeq, err := ReadSessionLogAfter(dir, "s", 2, seqOf, parseSeqEvent)
355
+ if err != nil {
356
+ t.Fatal(err)
357
+ }
358
+ if len(evs) != 2 || evs[0].Seq != 3 || evs[1].Seq != 4 || maxSeq != 4 {
359
+ t.Fatalf("尾部续读期望 [3 4]/maxSeq=4,实得 %+v/maxSeq=%d", evs, maxSeq)
360
+ }
361
+ st, _ = os.Stat(path)
362
+ if m := mustMark(t, path, 4); m.offset != st.Size() {
363
+ t.Fatalf("续读后游标 offset 期望 %d,实得 %+v", st.Size(), m)
364
+ }
365
+
366
+ // 追平:空页,且 maxSeq 必须仍等于 after(否则会被误判成「游标超前」)。
367
+ evs, maxSeq, err = ReadSessionLogAfter(dir, "s", 4, seqOf, parseSeqEvent)
368
+ if err != nil {
369
+ t.Fatal(err)
370
+ }
371
+ if len(evs) != 0 || maxSeq != 4 {
372
+ t.Fatalf("追平期望 空页/maxSeq=4,实得 %+v/maxSeq=%d", evs, maxSeq)
373
+ }
374
+
375
+ // 末尾半行(还没落换行)不能消费;补上换行后才算。
376
+ appendLog(t, path, `{"seq":5,"text":"e"}`)
377
+ evs, maxSeq, err = ReadSessionLogAfter(dir, "s", 4, seqOf, parseSeqEvent)
378
+ if err != nil {
379
+ t.Fatal(err)
380
+ }
381
+ if len(evs) != 0 || maxSeq != 4 {
382
+ t.Fatalf("半行不该被返回:%+v/maxSeq=%d", evs, maxSeq)
383
+ }
384
+ if _, ok := loadTailMark(path, 5); ok {
385
+ t.Fatal("半行不该推进游标")
386
+ }
387
+ appendLog(t, path, "\n")
388
+ evs, maxSeq, err = ReadSessionLogAfter(dir, "s", 4, seqOf, parseSeqEvent)
389
+ if err != nil {
390
+ t.Fatal(err)
391
+ }
392
+ if len(evs) != 1 || evs[0].Seq != 5 || maxSeq != 5 {
393
+ t.Fatalf("落完换行后应读到 seq=5:%+v/maxSeq=%d", evs, maxSeq)
394
+ }
395
+
396
+ // after 与游标不吻合 → 退回全量,结果依旧正确(只是慢)。
397
+ evs, maxSeq, err = ReadSessionLogAfter(dir, "s", 1, seqOf, parseSeqEvent)
398
+ if err != nil {
399
+ t.Fatal(err)
400
+ }
401
+ if len(evs) != 4 || evs[0].Seq != 2 || evs[3].Seq != 5 || maxSeq != 5 {
402
+ t.Fatalf("游标不吻合时应回退全量:%+v/maxSeq=%d", evs, maxSeq)
403
+ }
404
+ }
405
+
406
+ // TestTailMarkKeepsRecentCursors 多客户端场景:各自游标错开一两拍时都要命中,
407
+ // 只保留最近的几条(更老的自然回退全量)。
408
+ func TestTailMarkKeepsRecentCursors(t *testing.T) {
409
+ path := filepath.Join(t.TempDir(), "x.jsonl")
410
+ for seq := uint64(1); seq <= tailMarkKeep+2; seq++ {
411
+ storeTailMark(path, seq, int64(seq)*10)
412
+ }
413
+ for seq := uint64(1); seq <= 2; seq++ {
414
+ if _, ok := loadTailMark(path, seq); ok {
415
+ t.Fatalf("seq=%d 已超出保留窗口,不该还在", seq)
416
+ }
417
+ }
418
+ for seq := uint64(3); seq <= tailMarkKeep+2; seq++ {
419
+ if _, ok := loadTailMark(path, seq); !ok {
420
+ t.Fatalf("seq=%d 应仍在保留窗口内", seq)
421
+ }
422
+ }
423
+ // 同一 seq 以新偏移为准(文件被追加后重新算出更靠后的位置)。
424
+ storeTailMark(path, tailMarkKeep+2, 9999)
425
+ if m, _ := loadTailMark(path, tailMarkKeep+2); m.offset != 9999 {
426
+ t.Fatalf("同一 seq 应更新为新偏移,实得 %+v", m)
427
+ }
428
+ }
429
+
430
+ // TestReadSessionLogAfterTailMarkTruncated 文件被截断(日志重置 / 换了内容)时,
431
+ // 游标必须作废、退回全量:否则 seek 到旧偏移会读到越界或错位的内容。
432
+ func TestReadSessionLogAfterTailMarkTruncated(t *testing.T) {
433
+ dir := writeLog(t, "s", `{"seq":1,"text":"a"}`, `{"seq":2,"text":"b"}`, `{"seq":3,"text":"c"}`)
434
+ path := filepath.Join(dir, "s.jsonl")
435
+ seqOf := func(e seqEvent) uint64 { return e.Seq }
436
+
437
+ if _, _, err := ReadSessionLogAfter(dir, "s", 0, seqOf, parseSeqEvent); err != nil {
438
+ t.Fatal(err)
439
+ }
440
+ // 截回只剩 seq=1 那一行(比游标偏移还短)。
441
+ if err := os.WriteFile(path, []byte(`{"seq":1,"text":"a"}`+"\n"), 0o600); err != nil {
442
+ t.Fatal(err)
443
+ }
444
+ evs, maxSeq, err := ReadSessionLogAfter(dir, "s", 2, seqOf, parseSeqEvent)
445
+ if err != nil {
446
+ t.Fatalf("截断后不该报错:%v", err)
447
+ }
448
+ if len(evs) != 0 || maxSeq != 1 {
449
+ t.Fatalf("截断后期望 空页/maxSeq=1(调用方据此判超前重建),实得 %+v/maxSeq=%d", evs, maxSeq)
450
+ }
451
+ }
452
+
453
+ // TestReadSessionLogAfterLeanTailKeepsSlim 尾部续读也要走 slim(摘 snapshot)。
454
+ func TestReadSessionLogAfterLeanTailKeepsSlim(t *testing.T) {
455
+ dir := writeLog(t, "s", `{"seq":1,"text":"a","snapshot":"a"}`)
456
+ path := filepath.Join(dir, "s.jsonl")
457
+ seqOf := func(e leanSnapEvent) uint64 { return e.Seq }
458
+
459
+ // 先用非 lean 建游标,再跑 lean 尾部续读。
460
+ if _, _, err := ReadSessionLogAfter(dir, "s", 0, seqOf, parseLeanSnapEvent); err != nil {
461
+ t.Fatal(err)
462
+ }
463
+ appendLog(t, path, `{"seq":2,"text":"ab","snapshot":"ab"}`+"\n")
464
+ evs, maxSeq, err := ReadSessionLogAfterLean(dir, "s", 1, seqOf, parseLeanSnapEvent)
465
+ if err != nil {
466
+ t.Fatal(err)
467
+ }
468
+ if len(evs) != 1 || evs[0].Seq != 2 || evs[0].Text != "ab" || maxSeq != 2 {
469
+ t.Fatalf("lean 尾部续读结果不对:%+v/maxSeq=%d", evs, maxSeq)
470
+ }
471
+ if evs[0].Snapshot != "" {
472
+ t.Fatalf("尾部续读也必须摘掉 snapshot,实得 %q", evs[0].Snapshot)
473
+ }
474
+ }
475
+
476
+ /* ── run_id ↔ session_id:正在跑的会话也要读得到(2026-10-15)──
477
+ 用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
478
+ 日志文件名按 session_id 落(引擎还没吐 id 时 core 先按 run_id 落,见 cli 的 sessionWriter),
479
+ 而客户端手里的 id 往往还只是 run_id —— 不翻译就永远读到空历史(静默失败)。 */
480
+
481
+ // TestResolveLogIDWhileRunning 正在跑的会话(登记表里只有 run_id、或刚回填 session_id)
482
+ // 按 run_id 也要读得到内容。
483
+ func TestResolveLogIDWhileRunning(t *testing.T) {
484
+ dir := t.TempDir()
485
+ t.Setenv(EnvDir, dir) // 登记表与日志同目录(别碰真实的 ~/.magic-agent)
486
+
487
+ // ① 引擎还没吐 id:文件就在 run_id 名下(core 先按 run_id 落盘)。
488
+ if err := os.WriteFile(filepath.Join(dir, "run-y.jsonl"),
489
+ []byte(`{"seq":1,"kind":"text","text":"a"}`+"\n"), 0o600); err != nil {
490
+ t.Fatal(err)
491
+ }
492
+ writeRecordForTest(t, dir, Record{RunID: "run-y", State: StateRunning, StartedAt: time.Now(), UpdatedAt: time.Now()})
493
+ if got := ResolveLogID(dir, "run-y"); got != "run-y" {
494
+ t.Fatalf("id 还没到手时按 run_id 应命中同名文件,实得 %q", got)
495
+ }
496
+ if !HasSessionLog(dir, "run-y") {
497
+ t.Fatal("刚开跑的会话就该 has_log=true")
498
+ }
499
+
500
+ // ② 拿到 id 后文件改名成 <session_id>.jsonl:按 run_id 仍要读得到(查登记表翻译)。
501
+ writeRecordForTest(t, dir, Record{RunID: "run-x", SessionID: "sess-x", State: StateRunning, StartedAt: time.Now(), UpdatedAt: time.Now()})
502
+ if err := os.WriteFile(filepath.Join(dir, "sess-x.jsonl"),
503
+ []byte(`{"seq":1,"kind":"text","text":"b"}`+"\n"), 0o600); err != nil {
504
+ t.Fatal(err)
505
+ }
506
+ if got := ResolveLogID(dir, "run-x"); got != "sess-x" {
507
+ t.Fatalf("改名后按 run_id 应翻成 sess-x,实得 %q", got)
508
+ }
509
+ if !HasSessionLog(dir, "run-x") || !HasSessionLog(dir, "sess-x") {
510
+ t.Fatal("两个名字都该读得到同一条会话的日志")
511
+ }
512
+
513
+ // ③ 都没有 → 原样返回 + has_log=false(别把「没历史」伪装成别的)。
514
+ if got := ResolveLogID(dir, "nope"); got != "nope" {
515
+ t.Fatalf("翻译不出来时应原样返回,实得 %q", got)
516
+ }
517
+ if HasSessionLog(dir, "nope") {
518
+ t.Fatal("没有日志时 has_log 必须是 false")
519
+ }
520
+ }
521
+
522
+ // TestResolveLogIDBySessionIDWhenFileKeptRunID 引擎整轮不调 OnSessionID(codex 这一脉)
523
+ // → 日志文件**永远**叫 `<run_id>.jsonl`,而列表页与客户端手里的主键是 session_id。
524
+ //
525
+ // 只做单向翻译(run_id → session_id)救不了这种文件:按 session_id 去读,文件名对不上、
526
+ // Find 查回来又是同一个 session_id,于是读到空。表现是最难查的一种错位 ——
527
+ // 列表说 `has_log=true`、点开说「还没有事件」(用户 2026-10-18:「会话内容怎么没了」)。
528
+ func TestResolveLogIDBySessionIDWhenFileKeptRunID(t *testing.T) {
529
+ dir := t.TempDir()
530
+ t.Setenv(EnvDir, dir)
531
+
532
+ writeRecordForTest(t, dir, Record{
533
+ RunID: "run-keep", SessionID: "sess-keep", State: StateDone,
534
+ StartedAt: time.Now(), UpdatedAt: time.Now(),
535
+ })
536
+ if err := os.WriteFile(filepath.Join(dir, "run-keep.jsonl"),
537
+ []byte(`{"seq":1,"kind":"text","text":"a"}`+"\n"), 0o600); err != nil {
538
+ t.Fatal(err)
539
+ }
540
+
541
+ if got := ResolveLogID(dir, "sess-keep"); got != "run-keep" {
542
+ t.Fatalf("文件没改名时按 session_id 应翻成 run-keep,实得 %q", got)
543
+ }
544
+ if !HasSessionLog(dir, "sess-keep") {
545
+ t.Fatal("按 session_id 问 has_log 也必须是 true(否则列表与详情自相矛盾)")
546
+ }
547
+ // 反方向照旧:同名命中最先返回。
548
+ if got := ResolveLogID(dir, "run-keep"); got != "run-keep" {
549
+ t.Fatalf("按 run_id 读应原样命中,实得 %q", got)
550
+ }
551
+ // 两个 id 都不是的,原样返回(别把别人的会话认进来)。
552
+ if got := ResolveLogID(dir, "nope"); got != "nope" {
553
+ t.Fatalf("认不出来时应原样返回,实得 %q", got)
554
+ }
555
+ }
556
+
557
+ // writeRecordForTest 直接落一条登记记录(Begin 会自己生成 run_id,这里要指定它)。
558
+ func writeRecordForTest(t *testing.T, dir string, rec Record) {
559
+ t.Helper()
560
+ b, err := json.Marshal(rec)
561
+ if err != nil {
562
+ t.Fatal(err)
563
+ }
564
+ if err := os.WriteFile(filepath.Join(dir, rec.RunID+".json"), b, 0o600); err != nil {
565
+ t.Fatal(err)
566
+ }
567
+ }