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.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -2,6 +2,7 @@ package cli
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
|
+
"fmt"
|
|
5
6
|
"io"
|
|
6
7
|
"os"
|
|
7
8
|
"path/filepath"
|
|
@@ -217,6 +218,216 @@ func TestSessionWriterRealisticStream(t *testing.T) {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/* ── 2026-10-15:日志不再等 session_id 才创建 ──
|
|
222
|
+
用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
|
|
223
|
+
此前日志要等引擎吐 session_id 才知道文件名 → 正在跑的会话读出来永远是空历史;
|
|
224
|
+
引擎整轮不给 id 时(dsh 的 headless 回退轮)更是收工就把内存 buffer 丢光。 */
|
|
225
|
+
|
|
226
|
+
// TestSessionWriterStartsByRunIDThenRenames:先有 run_id 就先落盘(不等引擎的 id),
|
|
227
|
+
// 拿到 session_id 后改名成终名,内容一条不丢、顺序不变。
|
|
228
|
+
func TestSessionWriterStartsByRunIDThenRenames(t *testing.T) {
|
|
229
|
+
dir := t.TempDir()
|
|
230
|
+
sw := newSessionWriter(dir, 1)
|
|
231
|
+
defer sw.close()
|
|
232
|
+
sw.setRunID("run-abc")
|
|
233
|
+
sw.push(sessionEvent{Kind: "user", Text: "帮我改一下"})
|
|
234
|
+
|
|
235
|
+
// 引擎还没吐 id 的这段时间里,别的客户端就该读得到内容(1s 的定时 flush 兜底)。
|
|
236
|
+
waitFileLines(t, filepath.Join(dir, "run-abc.jsonl"), 1)
|
|
237
|
+
|
|
238
|
+
sw.setSessionID("sess-1")
|
|
239
|
+
sw.push(sessionEvent{Kind: "text", Text: "好"})
|
|
240
|
+
drain(t, sw)
|
|
241
|
+
|
|
242
|
+
if _, err := os.Stat(filepath.Join(dir, "run-abc.jsonl")); !os.IsNotExist(err) {
|
|
243
|
+
t.Errorf("改名后不该再留着 <run_id>.jsonl(err=%v)", err)
|
|
244
|
+
}
|
|
245
|
+
lines := readLines(t, filepath.Join(dir, "sess-1.jsonl"))
|
|
246
|
+
if len(lines) != 2 {
|
|
247
|
+
t.Fatalf("期望 2 行,实得 %d:%v", len(lines), lines)
|
|
248
|
+
}
|
|
249
|
+
var a, b sessionEvent
|
|
250
|
+
_ = json.Unmarshal([]byte(lines[0]), &a)
|
|
251
|
+
_ = json.Unmarshal([]byte(lines[1]), &b)
|
|
252
|
+
if a.Kind != "user" || b.Kind != "text" {
|
|
253
|
+
t.Fatalf("顺序错:%s / %s", a.Kind, b.Kind)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// TestSessionWriterKeepsLogWhenEngineNeverReportsID:引擎整轮不给 id 时,
|
|
258
|
+
// 内容也必须留在 <run_id>.jsonl —— 此前这种会话收工时 buffer 被丢光,
|
|
259
|
+
// --save-history 白开、一个字节都落不下来(静默失败)。
|
|
260
|
+
func TestSessionWriterKeepsLogWhenEngineNeverReportsID(t *testing.T) {
|
|
261
|
+
dir := t.TempDir()
|
|
262
|
+
sw := newSessionWriter(dir, 1)
|
|
263
|
+
sw.setRunID("run-noid")
|
|
264
|
+
sw.push(sessionEvent{Kind: "user", Text: "跑一下"})
|
|
265
|
+
sw.push(sessionEvent{Kind: "text", Text: "好了"})
|
|
266
|
+
sw.push(sessionEvent{Kind: "turn_end", Text: "好了"})
|
|
267
|
+
drain(t, sw)
|
|
268
|
+
|
|
269
|
+
lines := readLines(t, filepath.Join(dir, "run-noid.jsonl"))
|
|
270
|
+
if len(lines) != 3 {
|
|
271
|
+
t.Fatalf("引擎不给 id 时也要落满 3 行,实得 %d:%v", len(lines), lines)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// TestSessionWriterMergesIntoExistingFinalName:终名文件已存在时**不能覆盖** ——
|
|
276
|
+
// os.Rename 在 Unix 下会静默把已有历史删掉(续接 / 重跑同一会话会撞上)。
|
|
277
|
+
func TestSessionWriterMergesIntoExistingFinalName(t *testing.T) {
|
|
278
|
+
dir := t.TempDir()
|
|
279
|
+
old := filepath.Join(dir, "sess-dup.jsonl")
|
|
280
|
+
if err := os.WriteFile(old, []byte("{\"seq\":1,\"kind\":\"text\",\"text\":\"旧\",\"session_id\":\"sess-dup\"}\n"), 0o600); err != nil {
|
|
281
|
+
t.Fatal(err)
|
|
282
|
+
}
|
|
283
|
+
sw := newSessionWriter(dir, 1)
|
|
284
|
+
defer sw.close()
|
|
285
|
+
sw.setRunID("run-dup")
|
|
286
|
+
sw.push(sessionEvent{Kind: "user", Text: "再跑一轮"})
|
|
287
|
+
sw.setSessionID("sess-dup")
|
|
288
|
+
sw.push(sessionEvent{Kind: "text", Text: "新"})
|
|
289
|
+
drain(t, sw)
|
|
290
|
+
|
|
291
|
+
if _, err := os.Stat(filepath.Join(dir, "run-dup.jsonl")); !os.IsNotExist(err) {
|
|
292
|
+
t.Errorf("合并后不该留着 <run_id>.jsonl(err=%v)", err)
|
|
293
|
+
}
|
|
294
|
+
lines := readLines(t, old)
|
|
295
|
+
if len(lines) != 3 {
|
|
296
|
+
t.Fatalf("旧 1 行 + 新 2 行 = 3 行,实得 %d:%v", len(lines), lines)
|
|
297
|
+
}
|
|
298
|
+
if !strings.Contains(lines[0], "旧") || !strings.Contains(lines[2], "新") {
|
|
299
|
+
t.Fatalf("合并顺序错:%v", lines)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// TestSessionWriterUsesFinalNameWhenIDKnownUpfront:续接(一开始就知道 session_id)
|
|
304
|
+
// 直接用终名,不产生 <run_id>.jsonl。
|
|
305
|
+
func TestSessionWriterUsesFinalNameWhenIDKnownUpfront(t *testing.T) {
|
|
306
|
+
dir := t.TempDir()
|
|
307
|
+
sw := newSessionWriter(dir, 1)
|
|
308
|
+
sw.setRunID("run-resume")
|
|
309
|
+
sw.setSessionID("sess-resume")
|
|
310
|
+
sw.push(sessionEvent{Kind: "text", Text: "接着聊"})
|
|
311
|
+
drain(t, sw)
|
|
312
|
+
|
|
313
|
+
entries, err := os.ReadDir(dir)
|
|
314
|
+
if err != nil {
|
|
315
|
+
t.Fatal(err)
|
|
316
|
+
}
|
|
317
|
+
var names []string
|
|
318
|
+
for _, e := range entries {
|
|
319
|
+
names = append(names, e.Name())
|
|
320
|
+
}
|
|
321
|
+
if len(names) != 1 || names[0] != "sess-resume.jsonl" {
|
|
322
|
+
t.Fatalf("只该有 sess-resume.jsonl,实得 %v", names)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// waitFileLines 等文件出现且行数达到 want(写盘是异步的;1s 定时 flush 兜底)。
|
|
327
|
+
func waitFileLines(t *testing.T, path string, want int) {
|
|
328
|
+
t.Helper()
|
|
329
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
330
|
+
for time.Now().Before(deadline) {
|
|
331
|
+
if lines := readLinesIfAny(path); len(lines) >= want {
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
time.Sleep(20 * time.Millisecond)
|
|
335
|
+
}
|
|
336
|
+
t.Fatalf("%s 等不到 %d 行(实得 %d)", path, want, len(readLinesIfAny(path)))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// readLinesIfAny 读文件行数;文件不在或为空时返回 nil(**不** Fatal)。
|
|
340
|
+
func readLinesIfAny(path string) []string {
|
|
341
|
+
b, err := os.ReadFile(path)
|
|
342
|
+
if err != nil || len(b) == 0 {
|
|
343
|
+
return nil
|
|
344
|
+
}
|
|
345
|
+
return strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// TestSessionWriterSeqContinuesAcrossTurns 同一个日志文件跨轮追加时,seq 必须**接着上一轮**。
|
|
349
|
+
//
|
|
350
|
+
// 老行为是每轮从 1 重来(seq 只是「本进程内的行号」),于是同一个文件里出现多条 seq=1;
|
|
351
|
+
// 客户端回放按 seq 去重 → 第二轮起的事件被当重复帧丢掉 → 界面上**只显示第一轮**
|
|
352
|
+
// (用户 2026-10-16 报的「多轮对话只显示一轮」)。
|
|
353
|
+
func TestSessionWriterSeqContinuesAcrossTurns(t *testing.T) {
|
|
354
|
+
dir := t.TempDir()
|
|
355
|
+
path := filepath.Join(dir, "sess-seq.jsonl")
|
|
356
|
+
|
|
357
|
+
sw1 := newSessionWriter(dir, 1)
|
|
358
|
+
sw1.setSessionID("sess-seq")
|
|
359
|
+
sw1.push(sessionEvent{Kind: "user", Text: "第一轮"})
|
|
360
|
+
sw1.push(sessionEvent{Kind: "text", Text: "好"})
|
|
361
|
+
drain(t, sw1)
|
|
362
|
+
|
|
363
|
+
sw2 := newSessionWriter(dir, 2)
|
|
364
|
+
sw2.setSessionID("sess-seq")
|
|
365
|
+
sw2.push(sessionEvent{Kind: "user", Text: "第二轮"})
|
|
366
|
+
sw2.push(sessionEvent{Kind: "text", Text: "好的"})
|
|
367
|
+
drain(t, sw2)
|
|
368
|
+
|
|
369
|
+
lines := readLines(t, path)
|
|
370
|
+
if len(lines) != 4 {
|
|
371
|
+
t.Fatalf("两轮共 4 行,实得 %d:%v", len(lines), lines)
|
|
372
|
+
}
|
|
373
|
+
var seqs []uint64
|
|
374
|
+
for i, ln := range lines {
|
|
375
|
+
var ev sessionEvent
|
|
376
|
+
if err := json.Unmarshal([]byte(ln), &ev); err != nil {
|
|
377
|
+
t.Fatalf("第 %d 行解析失败:%v", i, err)
|
|
378
|
+
}
|
|
379
|
+
seqs = append(seqs, ev.Seq)
|
|
380
|
+
}
|
|
381
|
+
for i := 1; i < len(seqs); i++ {
|
|
382
|
+
if seqs[i] <= seqs[i-1] {
|
|
383
|
+
t.Fatalf("seq 必须跨轮严格递增(否则回放去重会丢轮),实得 %v", seqs)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 反向验证:读回来的事件里两轮的 user 都在(回放不会只剩一轮)。
|
|
388
|
+
evs, err := ReadSessionLog(dir, "sess-seq")
|
|
389
|
+
if err != nil {
|
|
390
|
+
t.Fatalf("读历史失败:%v", err)
|
|
391
|
+
}
|
|
392
|
+
users := 0
|
|
393
|
+
for _, ev := range evs {
|
|
394
|
+
if ev.Kind == "user" {
|
|
395
|
+
users++
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if users != 2 {
|
|
399
|
+
t.Fatalf("两轮对话应有 2 条 user,实得 %d", users)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// TestSessionWriterSessionIDIsTerminalName 日志文件名**统一成 session_id**。
|
|
404
|
+
//
|
|
405
|
+
// 一条会话的正常轨迹:先按 `<run_id>.jsonl` 开文件(引擎还没吐 id),id 到手后改名成
|
|
406
|
+
// `<session_id>.jsonl` 终名。这是「客户端不必猜文件名」的前提(2026-10-18,用户:
|
|
407
|
+
// 「runid 不能统一记成 sessionid 吗」)。
|
|
408
|
+
//
|
|
409
|
+
// ⚠️ 后半段是重点:id 到手之后,**不带 id 的事件(空串)不能把名字改回去** ——
|
|
410
|
+
// 引擎只在开头几条事件带 id 是常态,回退一次就又变回 run_id 名了。
|
|
411
|
+
func TestSessionWriterSessionIDIsTerminalName(t *testing.T) {
|
|
412
|
+
dir := t.TempDir()
|
|
413
|
+
sw := newSessionWriter(dir, 1)
|
|
414
|
+
sw.setRunID("run-x")
|
|
415
|
+
sw.push(sessionEvent{Kind: "text", Text: "a"}) // 还没有 id:先落在 run_id 名下
|
|
416
|
+
sw.setSessionID("sess-x") // id 到手 → 改名到终名
|
|
417
|
+
sw.push(sessionEvent{Kind: "text", Text: "b"})
|
|
418
|
+
sw.setSessionID("") // 后续事件不带 id(空串)→ 必须忽略
|
|
419
|
+
sw.push(sessionEvent{Kind: "text", Text: "c"})
|
|
420
|
+
drain(t, sw)
|
|
421
|
+
|
|
422
|
+
lines := readLines(t, filepath.Join(dir, "sess-x.jsonl"))
|
|
423
|
+
if len(lines) != 3 {
|
|
424
|
+
t.Fatalf("终名文件应有 3 行(pending 不丢、顺序不变),实得 %d:%v", len(lines), lines)
|
|
425
|
+
}
|
|
426
|
+
if _, err := os.Stat(filepath.Join(dir, "run-x.jsonl")); !os.IsNotExist(err) {
|
|
427
|
+
t.Error("改名后不该再留下 run_id 名的文件(空 id 把它改回去了?)")
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
220
431
|
// drain 等 writer 的 in 通道全部写完。close 之后再等它把所有 pending 全 flush。
|
|
221
432
|
func drain(t *testing.T, sw *sessionWriter) {
|
|
222
433
|
t.Helper()
|
|
@@ -260,3 +471,500 @@ func TestSessionWriterConcurrentSetIDAndPush(t *testing.T) {
|
|
|
260
471
|
wg.Wait()
|
|
261
472
|
sw.close()
|
|
262
473
|
}
|
|
474
|
+
|
|
475
|
+
// TestSessionLogBackwardCompatible 老 session log(没有收敛字段的)必须仍能读。
|
|
476
|
+
//
|
|
477
|
+
// 兼容性方向很重要:**新代码读旧数据**。字段全是 omitempty,所以旧 log 的
|
|
478
|
+
// 每一行仍能解析成 sessionEvent,收敛字段读出来是零值。
|
|
479
|
+
//
|
|
480
|
+
// 反方向不成立且不需要成立:旧代码读新 log 时,多出来的键会被
|
|
481
|
+
// encoding/json 忽略(没有 DisallowUnknownFields),也不会报错。
|
|
482
|
+
func TestSessionLogBackwardCompatible(t *testing.T) {
|
|
483
|
+
dir := t.TempDir()
|
|
484
|
+
cli := SessionLogDirOverride
|
|
485
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
486
|
+
SessionLogDirOverride = dir
|
|
487
|
+
|
|
488
|
+
// 一份 2026-10-02 之前写出的真实形状:没有任何收敛字段。
|
|
489
|
+
legacy := `{"kind":"thinking","text":"我应该","seq":1}
|
|
490
|
+
{"kind":"text","text":"收到","seq":2,"session_id":"sess-old"}
|
|
491
|
+
{"kind":"turn_end","text":"收到","seq":3,"session_id":"sess-old"}
|
|
492
|
+
`
|
|
493
|
+
path := filepath.Join(dir, "sess-old.jsonl")
|
|
494
|
+
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
|
|
495
|
+
t.Fatal(err)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
events, err := ReadSessionLog(dir, "sess-old")
|
|
499
|
+
if err != nil {
|
|
500
|
+
t.Fatalf("读旧 log 失败: %v", err)
|
|
501
|
+
}
|
|
502
|
+
if len(events) != 3 {
|
|
503
|
+
t.Fatalf("事件数 = %d,want 3", len(events))
|
|
504
|
+
}
|
|
505
|
+
for i, ev := range events {
|
|
506
|
+
if ev.ItemID != "" || ev.ItemRevision != 0 || ev.Status != "" || ev.Snapshot != "" {
|
|
507
|
+
t.Errorf("旧 log 的收敛字段应为零值,第 %d 条却是 %+v", i, ev)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// 旧数据的关键内容仍要读得出来。
|
|
511
|
+
if events[2].Kind != "turn_end" || events[2].Text != "收到" {
|
|
512
|
+
t.Errorf("旧 log 内容读取错误: %+v", events[2])
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// TestStripSnapshots 守住 --session-log-lean 的口径:只清 snapshot,
|
|
517
|
+
// 增量 text / item 收敛字段一律不动(回放全靠它们)。
|
|
518
|
+
func TestStripSnapshots(t *testing.T) {
|
|
519
|
+
evs := []sessionEvent{
|
|
520
|
+
{Kind: "thinking", Text: "先", ItemID: "t1", ItemRevision: 1, Status: "running", Snapshot: "先"},
|
|
521
|
+
{Kind: "thinking", Text: "想", ItemID: "t1", ItemRevision: 2, Status: "running", Snapshot: "先想"},
|
|
522
|
+
{Kind: "text", Text: "收到", ItemID: "m1", ItemRevision: 1, Status: "final", Snapshot: "收到"},
|
|
523
|
+
}
|
|
524
|
+
stripSnapshots(evs)
|
|
525
|
+
for i, ev := range evs {
|
|
526
|
+
if ev.Snapshot != "" {
|
|
527
|
+
t.Errorf("第 %d 条 snapshot 未清掉: %q", i, ev.Snapshot)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if evs[1].Text != "想" || evs[1].ItemID != "t1" || evs[1].ItemRevision != 2 || evs[1].Status != "running" {
|
|
531
|
+
t.Errorf("增量/收敛字段被误伤: %+v", evs[1])
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// TestSessionLogRoundTripsConvergenceFields 新写出的 log 必须带上收敛三元组
|
|
536
|
+
// (item_id / item_revision / status),且**只靠增量 text** 就能复原出与实时流
|
|
537
|
+
// 一致的收敛结果。
|
|
538
|
+
//
|
|
539
|
+
// ⚠️ 2026-10-20 起落盘**不写 snapshot**(见 appendLine):它是「该 item 到此刻的
|
|
540
|
+
// 累积整段」,每条增量都带一份 ⇒ 二次方膨胀(实测一条会话 1.8GB,95.6% 是它)。
|
|
541
|
+
// 回放侧本来就是只用增量 text 自己拼(client-ui/shared/conv/09-stream-apply.js),
|
|
542
|
+
// 所以「落盘数据足以复原收敛结果」这条要求改由增量累加兑现。
|
|
543
|
+
func TestSessionLogRoundTripsConvergenceFields(t *testing.T) {
|
|
544
|
+
dir := t.TempDir()
|
|
545
|
+
cli := SessionLogDirOverride
|
|
546
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
547
|
+
SessionLogDirOverride = dir
|
|
548
|
+
|
|
549
|
+
sw, err := newSessionWriterAuto(sessionLogDir)
|
|
550
|
+
if err != nil {
|
|
551
|
+
t.Fatal(err)
|
|
552
|
+
}
|
|
553
|
+
sw.setSessionID("sess-conv")
|
|
554
|
+
// 真实 Claude 一轮:思考两条 item 各两帧 + 正文一条 item + 终帧。
|
|
555
|
+
sw.push(sessionEvent{Kind: "thinking", Text: "先", ItemID: "claude_think_msg_x", ItemRevision: 1, Status: "running", Snapshot: "先"})
|
|
556
|
+
sw.push(sessionEvent{Kind: "thinking", Text: "想", ItemID: "claude_think_msg_x", ItemRevision: 2, Status: "running", Snapshot: "先想"})
|
|
557
|
+
sw.push(sessionEvent{Kind: "text", Text: "2", ItemID: "claude_msg_msg_x", ItemRevision: 1, Status: "running", Snapshot: "2"})
|
|
558
|
+
sw.push(sessionEvent{Kind: "turn_end", Text: "2", ItemID: "claude_msg_msg_x", ItemRevision: 2, Status: "final", Snapshot: "2"})
|
|
559
|
+
drain(t, sw)
|
|
560
|
+
|
|
561
|
+
events, err := ReadSessionLog(dir, "sess-conv")
|
|
562
|
+
if err != nil {
|
|
563
|
+
t.Fatal(err)
|
|
564
|
+
}
|
|
565
|
+
if len(events) != 4 {
|
|
566
|
+
t.Fatalf("事件数 = %d,want 4", len(events))
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 落盘不得再带 snapshot(那是体积爆炸的根源)。
|
|
570
|
+
for i, ev := range events {
|
|
571
|
+
if ev.Snapshot != "" {
|
|
572
|
+
t.Errorf("第 %d 条仍带 snapshot(落盘不该再写它): %q", i, ev.Snapshot)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 按 item 覆盖式 upsert 回放:**增量 text 累加**出收敛结果
|
|
577
|
+
//(与回放侧 qaStreamApply 的 appendThinking/appendText 同一口径)。
|
|
578
|
+
// ⚠️ turn_end 的 text 是**权威全文**(覆盖,不是增量)—— 回放侧走 setResult,
|
|
579
|
+
// 这里也照它那样覆盖,否则会把终帧算成又一段增量。
|
|
580
|
+
final := map[string]string{}
|
|
581
|
+
for _, ev := range events {
|
|
582
|
+
if ev.ItemID == "" {
|
|
583
|
+
t.Fatalf("落盘缺 item_id: %+v", ev)
|
|
584
|
+
}
|
|
585
|
+
if ev.Kind == "turn_end" {
|
|
586
|
+
final[ev.ItemID] = ev.Text
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
final[ev.ItemID] += ev.Text
|
|
590
|
+
}
|
|
591
|
+
if final["claude_msg_msg_x"] != "2" {
|
|
592
|
+
t.Errorf("正文 item 收敛结果 = %q,want \"2\"", final["claude_msg_msg_x"])
|
|
593
|
+
}
|
|
594
|
+
if final["claude_think_msg_x"] != "先想" {
|
|
595
|
+
t.Errorf("思考 item 收敛结果 = %q,want \"先想\"", final["claude_think_msg_x"])
|
|
596
|
+
}
|
|
597
|
+
// 两条 item 都在,且互不覆盖 —— 这正是拆 lane 的目的。
|
|
598
|
+
if len(final) != 2 {
|
|
599
|
+
t.Errorf("应收敛出 2 条 item,实得 %d:%v", len(final), final)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ── 2026-10-20:向上翻历史(--before / --limit) ──
|
|
604
|
+
用户要求:「默认加载最近的一轮用户和回复的对话即可」,向上滚动再按页补更早的。
|
|
605
|
+
长会话全量读一遍几百 MB 等不起,所以 core 从文件尾**反向分块**读,攒够一页就停。 */
|
|
606
|
+
|
|
607
|
+
// writeSeqLog 造一份 seq 1..n 的日志;userSeqs 里的 seq 写成 kind=user。
|
|
608
|
+
func writeSeqLog(t *testing.T, dir, id string, n int, userSeqs ...int) {
|
|
609
|
+
t.Helper()
|
|
610
|
+
isUser := map[int]bool{}
|
|
611
|
+
for _, s := range userSeqs {
|
|
612
|
+
isUser[s] = true
|
|
613
|
+
}
|
|
614
|
+
var b strings.Builder
|
|
615
|
+
for i := 1; i <= n; i++ {
|
|
616
|
+
kind := "text"
|
|
617
|
+
if isUser[i] {
|
|
618
|
+
kind = "user"
|
|
619
|
+
}
|
|
620
|
+
fmt.Fprintf(&b, "{\"seq\":%d,\"kind\":\"%s\",\"text\":\"t%d\",\"session_id\":\"%s\"}\n", i, kind, i, id)
|
|
621
|
+
}
|
|
622
|
+
if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(b.String()), 0o600); err != nil {
|
|
623
|
+
t.Fatal(err)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// TestReadSessionLogBeforePages 从尾往前翻页:页与页不重叠、不丢事件、升序输出。
|
|
628
|
+
func TestReadSessionLogBeforePages(t *testing.T) {
|
|
629
|
+
dir := t.TempDir()
|
|
630
|
+
// 每页页首放一条 user:true 日志的每一轮都以 user 开头,而 readSessionLogBefore 现在
|
|
631
|
+
// 会「没见到轮次起点就继续往前读」,没有 user 的合成日志会被整份读成一页。
|
|
632
|
+
writeSeqLog(t, dir, "s", 50, 11, 21, 31, 41)
|
|
633
|
+
|
|
634
|
+
pos := uint64(0) // 0 = 从文件尾开始(跳过 0 条)
|
|
635
|
+
var pages [][]uint64
|
|
636
|
+
for page := 0; page < 10; page++ {
|
|
637
|
+
evs, next, more, err := ReadSessionLogBefore(dir, "s", pos, 10)
|
|
638
|
+
if err != nil {
|
|
639
|
+
t.Fatalf("第 %d 页失败:%v", page, err)
|
|
640
|
+
}
|
|
641
|
+
if len(evs) == 0 {
|
|
642
|
+
t.Fatalf("第 %d 页为空(more=%v next=%d)", page, more, next)
|
|
643
|
+
}
|
|
644
|
+
// 第 0 页是最新的 41..50,第 1 页 31..40 …… 每页内部升序。
|
|
645
|
+
hi := uint64(50 - page*10)
|
|
646
|
+
lo := hi - 9
|
|
647
|
+
if uint64(len(evs)) != 10 || evs[0].Seq != lo || evs[len(evs)-1].Seq != hi {
|
|
648
|
+
last := uint64(0)
|
|
649
|
+
if len(evs) > 0 {
|
|
650
|
+
last = evs[len(evs)-1].Seq
|
|
651
|
+
}
|
|
652
|
+
t.Fatalf("第 %d 页范围错:%d..%d(want %d..%d)", page, evs[0].Seq, last, lo, hi)
|
|
653
|
+
}
|
|
654
|
+
var seqs []uint64
|
|
655
|
+
for i, ev := range evs {
|
|
656
|
+
if i > 0 && ev.Seq <= evs[i-1].Seq {
|
|
657
|
+
t.Fatalf("页内必须升序:%v", []uint64{evs[i-1].Seq, ev.Seq})
|
|
658
|
+
}
|
|
659
|
+
seqs = append(seqs, ev.Seq)
|
|
660
|
+
}
|
|
661
|
+
pages = append(pages, seqs)
|
|
662
|
+
if !more {
|
|
663
|
+
break
|
|
664
|
+
}
|
|
665
|
+
// 下一页游标由 core 返回(含坏行/页头对齐的扣减),调用方不要自己加条数。
|
|
666
|
+
if next <= pos {
|
|
667
|
+
t.Fatalf("游标没前进:%d → %d(会死循环)", pos, next)
|
|
668
|
+
}
|
|
669
|
+
pos = next
|
|
670
|
+
}
|
|
671
|
+
// 页与页不重叠、不丢事件:页序反转(旧页在前)+ 页内升序,正好是 1..50。
|
|
672
|
+
var got []uint64
|
|
673
|
+
for i := len(pages) - 1; i >= 0; i-- {
|
|
674
|
+
got = append(got, pages[i]...)
|
|
675
|
+
}
|
|
676
|
+
if len(got) != 50 {
|
|
677
|
+
t.Fatalf("翻完应得 50 条,实得 %d:%v", len(got), got)
|
|
678
|
+
}
|
|
679
|
+
for i, s := range got {
|
|
680
|
+
if want := uint64(i + 1); s != want {
|
|
681
|
+
t.Fatalf("第 %d 条 = %d,want %d(重复或漏读)", i, s, want)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// TestReadSessionLogBeforeAcrossChunks 页面跨越 512KB 块边界时不能丢行/串行。
|
|
687
|
+
func TestReadSessionLogBeforeAcrossChunks(t *testing.T) {
|
|
688
|
+
dir := t.TempDir()
|
|
689
|
+
pad := strings.Repeat("x", 2048)
|
|
690
|
+
var b strings.Builder
|
|
691
|
+
const n = 800 // ≈ 1.7MB,必然跨块
|
|
692
|
+
// 每 100 条一条 user(页首),否则「一页至少含一个轮次起点」会一路读回文件头。
|
|
693
|
+
isUser := map[int]bool{1: true}
|
|
694
|
+
for i := 101; i <= n; i += 100 {
|
|
695
|
+
isUser[i] = true
|
|
696
|
+
}
|
|
697
|
+
for i := 1; i <= n; i++ {
|
|
698
|
+
kind := "text"
|
|
699
|
+
if isUser[i] {
|
|
700
|
+
kind = "user"
|
|
701
|
+
}
|
|
702
|
+
fmt.Fprintf(&b, "{\"seq\":%d,\"kind\":\"%s\",\"text\":\"%03d-%s\",\"session_id\":\"big\"}\n", i, kind, i, pad)
|
|
703
|
+
}
|
|
704
|
+
if err := os.WriteFile(filepath.Join(dir, "big.jsonl"), []byte(b.String()), 0o600); err != nil {
|
|
705
|
+
t.Fatal(err)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
evs, next, more, err := ReadSessionLogBefore(dir, "big", 0, 100)
|
|
709
|
+
if err != nil {
|
|
710
|
+
t.Fatal(err)
|
|
711
|
+
}
|
|
712
|
+
if len(evs) != 100 || !more {
|
|
713
|
+
t.Fatalf("首屏应 100 条且还有更早,实得 %d (more=%v)", len(evs), more)
|
|
714
|
+
}
|
|
715
|
+
if evs[0].Seq != uint64(n-99) || evs[len(evs)-1].Seq != n {
|
|
716
|
+
t.Fatalf("首屏范围错:first=%d last=%d", evs[0].Seq, evs[len(evs)-1].Seq)
|
|
717
|
+
}
|
|
718
|
+
// 跨块页:skip = core 返回的游标,起点落在第 2 块中间附近。
|
|
719
|
+
if next != 100 {
|
|
720
|
+
t.Fatalf("首屏游标应为 100,实得 %d", next)
|
|
721
|
+
}
|
|
722
|
+
evs2, _, _, err := ReadSessionLogBefore(dir, "big", next, 100)
|
|
723
|
+
if err != nil {
|
|
724
|
+
t.Fatal(err)
|
|
725
|
+
}
|
|
726
|
+
if len(evs2) != 100 {
|
|
727
|
+
t.Fatalf("第二页应 100 条,实得 %d", len(evs2))
|
|
728
|
+
}
|
|
729
|
+
for i := 1; i < len(evs2); i++ {
|
|
730
|
+
if evs2[i].Seq != evs2[i-1].Seq+1 {
|
|
731
|
+
t.Fatalf("跨块页 seq 断裂:%d → %d", evs2[i-1].Seq, evs2[i].Seq)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if !strings.HasPrefix(evs2[len(evs2)-1].Text, fmt.Sprintf("%03d-", evs2[len(evs2)-1].Seq)) {
|
|
735
|
+
t.Fatalf("跨块行内容串了:seq=%d text=%q", evs2[len(evs2)-1].Seq, evs2[len(evs2)-1].Text[:16])
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// TestReadSessionLogBeforeLean 反向读同样要在解码前摘掉 snapshot。
|
|
740
|
+
func TestReadSessionLogBeforeLean(t *testing.T) {
|
|
741
|
+
dir := t.TempDir()
|
|
742
|
+
line := `{"seq":1,"kind":"thinking","text":"想","snapshot":"想了很久","session_id":"lean"}` + "\n"
|
|
743
|
+
if err := os.WriteFile(filepath.Join(dir, "lean.jsonl"), []byte(line), 0o600); err != nil {
|
|
744
|
+
t.Fatal(err)
|
|
745
|
+
}
|
|
746
|
+
evs, _, _, err := ReadSessionLogBeforeLean(dir, "lean", 0, 10)
|
|
747
|
+
if err != nil {
|
|
748
|
+
t.Fatal(err)
|
|
749
|
+
}
|
|
750
|
+
if len(evs) != 1 || evs[0].Text != "想" || evs[0].Snapshot != "" {
|
|
751
|
+
t.Fatalf("lean 反向读结果错:%+v", evs)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// TestReadSessionLogBeforeAcrossSeqRestart 续跑后 seq 从 1 重新开始,翻页仍要跨得过去。
|
|
756
|
+
//
|
|
757
|
+
// 这是 2026-10-20 的真机缺陷:此前用 `seq < before` 当游标,翻到 seq=1 就再也读不到
|
|
758
|
+
// 更早的 run(实测一份 15737 行的会话里 13298 条永远看不到,顶部却写着「还有 13298 条」)。
|
|
759
|
+
// 改为按「从末尾跳过多少条」分页后,物理顺序是唯一依据,seq 怎么编号都无所谓。
|
|
760
|
+
func TestReadSessionLogBeforeAcrossSeqRestart(t *testing.T) {
|
|
761
|
+
dir := t.TempDir()
|
|
762
|
+
var b strings.Builder
|
|
763
|
+
for i := 1; i <= 10; i++ {
|
|
764
|
+
kind := "text"
|
|
765
|
+
if i == 1 {
|
|
766
|
+
kind = "user"
|
|
767
|
+
}
|
|
768
|
+
fmt.Fprintf(&b, "{\"seq\":%d,\"kind\":\"%s\",\"text\":\"a%d\",\"session_id\":\"restart\"}\n", i, kind, i)
|
|
769
|
+
}
|
|
770
|
+
b.WriteString("{not json\n") // 坏行也计入跳过条数(与正向 total 同口径)
|
|
771
|
+
for i := 1; i <= 8; i++ {
|
|
772
|
+
kind := "text"
|
|
773
|
+
if i == 1 {
|
|
774
|
+
kind = "user"
|
|
775
|
+
}
|
|
776
|
+
fmt.Fprintf(&b, "{\"seq\":%d,\"kind\":\"%s\",\"text\":\"b%d\",\"session_id\":\"restart\"}\n", i, kind, i)
|
|
777
|
+
}
|
|
778
|
+
if err := os.WriteFile(filepath.Join(dir, "restart.jsonl"), []byte(b.String()), 0o600); err != nil {
|
|
779
|
+
t.Fatal(err)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
var got []string
|
|
783
|
+
skip := uint64(0)
|
|
784
|
+
for page := 0; page < 10; page++ {
|
|
785
|
+
evs, next, more, err := ReadSessionLogBefore(dir, "restart", skip, 5)
|
|
786
|
+
if err != nil {
|
|
787
|
+
t.Fatalf("第 %d 页失败:%v", page, err)
|
|
788
|
+
}
|
|
789
|
+
var texts []string
|
|
790
|
+
for _, ev := range evs {
|
|
791
|
+
texts = append(texts, ev.Text)
|
|
792
|
+
}
|
|
793
|
+
got = append(texts, got...) // 页是新→旧读回的,倒着拼回升序
|
|
794
|
+
if !more {
|
|
795
|
+
break
|
|
796
|
+
}
|
|
797
|
+
if len(evs) == 0 {
|
|
798
|
+
t.Fatalf("第 %d 页为空但 more=true(skip=%d)", page, skip)
|
|
799
|
+
}
|
|
800
|
+
// 必须用 core 返回的游标:坏行占位置但不返回,拿本页条数相加会把
|
|
801
|
+
// 同一条事件重复取回(skip 从 5 变 9 时 a10 会被再读一次)。
|
|
802
|
+
if next <= skip {
|
|
803
|
+
t.Fatalf("第 %d 页游标没前进:%d → %d", page, skip, next)
|
|
804
|
+
}
|
|
805
|
+
skip = next
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
var want []string
|
|
809
|
+
for i := 1; i <= 10; i++ {
|
|
810
|
+
want = append(want, fmt.Sprintf("a%d", i))
|
|
811
|
+
}
|
|
812
|
+
for i := 1; i <= 8; i++ {
|
|
813
|
+
want = append(want, fmt.Sprintf("b%d", i))
|
|
814
|
+
}
|
|
815
|
+
if len(got) != len(want) {
|
|
816
|
+
t.Fatalf("翻完应得 %d 条,实得 %d:%v", len(want), len(got), got)
|
|
817
|
+
}
|
|
818
|
+
for i := range want {
|
|
819
|
+
if got[i] != want[i] {
|
|
820
|
+
t.Fatalf("第 %d 条 = %q,want %q(重复或漏读):%v", i, got[i], want[i], got)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// TestSessionLogBeforeAlignsToUser 页头必须裁到该页最早一条 user:
|
|
826
|
+
// 客户端按轮渲染,页头切在轮次中间会让思考/正文各半截。
|
|
827
|
+
// 被裁掉的几条不算丢 —— more=true,且 nextBefore 已经退回到「被裁那几条之前」。
|
|
828
|
+
func TestSessionLogBeforeAlignsToUser(t *testing.T) {
|
|
829
|
+
dir := t.TempDir()
|
|
830
|
+
cli := SessionLogDirOverride
|
|
831
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
832
|
+
SessionLogDirOverride = dir
|
|
833
|
+
// user 落在 25(上一页中间)与 41:本页 31..50 里最早一条 user 是 41。
|
|
834
|
+
writeSeqLog(t, dir, "s", 50, 1, 11, 21, 25, 41)
|
|
835
|
+
|
|
836
|
+
out, _, err := runAskCmd(t, "", "--session-log", "s", "--before", "0", "--limit", "20")
|
|
837
|
+
if err != nil {
|
|
838
|
+
t.Fatalf("--before 失败:%v", err)
|
|
839
|
+
}
|
|
840
|
+
var got struct {
|
|
841
|
+
Count int `json:"count"`
|
|
842
|
+
FirstSeq uint64 `json:"firstSeq"`
|
|
843
|
+
NextBefore uint64 `json:"nextBefore"`
|
|
844
|
+
More bool `json:"more"`
|
|
845
|
+
Events []sessionEvent `json:"events"`
|
|
846
|
+
}
|
|
847
|
+
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
|
848
|
+
t.Fatalf("输出不是 JSON:%v\n%s", err, out)
|
|
849
|
+
}
|
|
850
|
+
// 原始页是 31..50;最早 user 在 41 → 应裁成 41..50。
|
|
851
|
+
// 下一页要跳过 42..50(9 条)+ 41 自己 = 10 条,所以 nextBefore=10。
|
|
852
|
+
if got.Count != 10 || got.FirstSeq != 41 || !got.More {
|
|
853
|
+
t.Fatalf("页头对齐错:count=%d first=%d more=%v", got.Count, got.FirstSeq, got.More)
|
|
854
|
+
}
|
|
855
|
+
if got.NextBefore != 10 {
|
|
856
|
+
t.Fatalf("nextBefore 应退回被裁部分:%d(want 10)", got.NextBefore)
|
|
857
|
+
}
|
|
858
|
+
if got.Events[0].Kind != "user" || got.Events[0].Seq != 41 {
|
|
859
|
+
t.Fatalf("页首应是 user@41:%+v", got.Events[0])
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// TestSessionLogBeforeKeepsWholeTurn 一页必须至少含一个轮次起点(user)。
|
|
864
|
+
//
|
|
865
|
+
// 2026-10-20 用户:「滚一下至少也要补一轮回话 不是只补几个子过程」。思考密集的一轮里
|
|
866
|
+
// 一页 20 条可能全是同一轮的思考碎片 —— 页内没有 user,前插到画布上只多出几行子过程,
|
|
867
|
+
// 看着像「滚了没反应」。所以凑够 limit 仍没见到 user 时要继续往前读,直到见到为止。
|
|
868
|
+
func TestSessionLogBeforeKeepsWholeTurn(t *testing.T) {
|
|
869
|
+
dir := t.TempDir()
|
|
870
|
+
cli := SessionLogDirOverride
|
|
871
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
872
|
+
SessionLogDirOverride = dir
|
|
873
|
+
// 两轮:user@1 与 user@100;100 之后是 300 条思考碎片(模拟单轮被撑爆)。
|
|
874
|
+
writeSeqLog(t, dir, "s", 400, 1, 100)
|
|
875
|
+
|
|
876
|
+
out, _, err := runAskCmd(t, "", "--session-log", "s", "--before", "0", "--limit", "20")
|
|
877
|
+
if err != nil {
|
|
878
|
+
t.Fatalf("--before 失败:%v", err)
|
|
879
|
+
}
|
|
880
|
+
var got struct {
|
|
881
|
+
Count int `json:"count"`
|
|
882
|
+
FirstSeq uint64 `json:"firstSeq"`
|
|
883
|
+
NextBefore uint64 `json:"nextBefore"`
|
|
884
|
+
More bool `json:"more"`
|
|
885
|
+
Events []sessionEvent `json:"events"`
|
|
886
|
+
}
|
|
887
|
+
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
|
888
|
+
t.Fatalf("输出不是 JSON:%v\n%s", err, out)
|
|
889
|
+
}
|
|
890
|
+
// 起点应退到 user@100,把这一轮完整带回来(100..400),而不是只给 381..400。
|
|
891
|
+
if got.Count != 301 || got.FirstSeq != 100 {
|
|
892
|
+
t.Fatalf("应整轮返回 100..400(301 条),实得 %d 条 first=%d", got.Count, got.FirstSeq)
|
|
893
|
+
}
|
|
894
|
+
if got.Events[0].Kind != "user" || got.Events[0].Seq != 100 {
|
|
895
|
+
t.Fatalf("页首应是 user@100:%+v", got.Events[0])
|
|
896
|
+
}
|
|
897
|
+
if got.More != true {
|
|
898
|
+
t.Error("user@1 那一轮还没读(1..99),more 应为 true")
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// TestSessionLogBeforeTurnCap 一轮长到超过上限时按 maxTurnPage 收手(不整份读进内存)。
|
|
903
|
+
func TestSessionLogBeforeTurnCap(t *testing.T) {
|
|
904
|
+
dir := t.TempDir()
|
|
905
|
+
cli := SessionLogDirOverride
|
|
906
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
907
|
+
SessionLogDirOverride = dir
|
|
908
|
+
writeSeqLog(t, dir, "s", 400, 1) // 只有一条 user,后面 399 条思考
|
|
909
|
+
|
|
910
|
+
out, _, err := runAskCmd(t, "", "--session-log", "s", "--before", "0", "--limit", "2")
|
|
911
|
+
if err != nil {
|
|
912
|
+
t.Fatalf("--before 失败:%v", err)
|
|
913
|
+
}
|
|
914
|
+
var got struct {
|
|
915
|
+
Count int `json:"count"`
|
|
916
|
+
FirstSeq uint64 `json:"firstSeq"`
|
|
917
|
+
NextBefore uint64 `json:"nextBefore"`
|
|
918
|
+
More bool `json:"more"`
|
|
919
|
+
}
|
|
920
|
+
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
|
921
|
+
t.Fatalf("输出不是 JSON:%v\n%s", err, out)
|
|
922
|
+
}
|
|
923
|
+
// limit=2 → 上限 50 条:应停在 351..400,不能一路读到 user@1。
|
|
924
|
+
if got.Count != 50 || got.FirstSeq != 351 || !got.More {
|
|
925
|
+
t.Fatalf("上限应收手在 50 条:count=%d first=%d more=%v", got.Count, got.FirstSeq, got.More)
|
|
926
|
+
}
|
|
927
|
+
if got.NextBefore != 50 {
|
|
928
|
+
t.Fatalf("nextBefore 应为 50,实得 %d", got.NextBefore)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// TestSessionLogBeforeArgs --before 与 --after 互斥;单给翻页参数要被拒绝(不静默忽略)。
|
|
933
|
+
func TestSessionLogBeforeArgs(t *testing.T) {
|
|
934
|
+
dir := t.TempDir()
|
|
935
|
+
cli := SessionLogDirOverride
|
|
936
|
+
defer func() { SessionLogDirOverride = cli }()
|
|
937
|
+
SessionLogDirOverride = dir
|
|
938
|
+
// user@4 是第二页的页首:没有它,「一页至少含一个轮次起点」会把 1..5 整份读成一页。
|
|
939
|
+
writeSeqLog(t, dir, "s", 5, 4)
|
|
940
|
+
|
|
941
|
+
if _, _, err := runAskCmd(t, "", "--session-log", "s", "--before", "5", "--after", "1"); err == nil {
|
|
942
|
+
t.Error("--before 与 --after 同时给应报错")
|
|
943
|
+
}
|
|
944
|
+
// --before 0 是合法的最新一页(跳过 0 条),不再当参数错。
|
|
945
|
+
out, _, err := runAskCmd(t, "", "--session-log", "s", "--before", "0", "--limit", "2")
|
|
946
|
+
if err != nil {
|
|
947
|
+
t.Fatalf("--before 0 应是最新一页:%v", err)
|
|
948
|
+
}
|
|
949
|
+
var got struct {
|
|
950
|
+
Count int `json:"count"`
|
|
951
|
+
NextBefore uint64 `json:"nextBefore"`
|
|
952
|
+
More bool `json:"more"`
|
|
953
|
+
Events []sessionEvent `json:"events"`
|
|
954
|
+
}
|
|
955
|
+
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
|
956
|
+
t.Fatalf("输出不是 JSON:%v\n%s", err, out)
|
|
957
|
+
}
|
|
958
|
+
if got.Count != 2 || got.Events[0].Seq != 4 || got.Events[1].Seq != 5 || !got.More {
|
|
959
|
+
t.Fatalf("--before 0 应返回最后 2 条:count=%d more=%v events=%d", got.Count, got.More, len(got.Events))
|
|
960
|
+
}
|
|
961
|
+
if got.NextBefore != 2 {
|
|
962
|
+
t.Fatalf("nextBefore 应为 2,实得 %d", got.NextBefore)
|
|
963
|
+
}
|
|
964
|
+
if _, _, err := runAskCmd(t, "", "-p", "hi", "--before", "5"); err == nil {
|
|
965
|
+
t.Error("--before 不带 --session-log 应报错")
|
|
966
|
+
}
|
|
967
|
+
if _, _, err := runAskCmd(t, "", "-p", "hi", "--limit", "5"); err == nil {
|
|
968
|
+
t.Error("--limit 不带 --session-log 应报错")
|
|
969
|
+
}
|
|
970
|
+
}
|