From 979a89b3c2701a7d270cd6749a075ee1f12e6053 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:55:50 +0800 Subject: [PATCH] fix(session): preserve archived history across summarize truncation --- pkg/session/jsonl_backend.go | 10 +++-- pkg/session/jsonl_backend_test.go | 58 +++++++++++++++++++++++++++ web/backend/api/session.go | 15 +++++-- web/backend/api/session_test.go | 66 +++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go index 7f470de15..c1fc38724 100644 --- a/pkg/session/jsonl_backend.go +++ b/pkg/session/jsonl_backend.go @@ -68,11 +68,13 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { } } -// Save persists session state. Since the JSONL store fsyncs every write -// immediately, the data is already durable. Save runs compaction to reclaim -// space from logically truncated messages (no-op when there are none). +// Save persists session state. JSONL writes are already durable because +// Add*/Set*/Truncate* fsync metadata/data on each write, so Save is a no-op. +// +// Keeping Save non-compacting preserves full append-only JSONL archives for +// UI/history inspection even when runtime context uses logical truncation. func (b *JSONLBackend) Save(key string) error { - return b.store.Compact(context.Background(), key) + return nil } // Close releases resources held by the underlying store. diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go index 40fa019cb..849286794 100644 --- a/pkg/session/jsonl_backend_test.go +++ b/pkg/session/jsonl_backend_test.go @@ -1,7 +1,11 @@ package session_test import ( + "bufio" "fmt" + "os" + "path/filepath" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/memory" @@ -177,3 +181,57 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) { t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") } } + +func TestJSONLBackend_SavePreservesArchivedJSONLLinesAfterTruncate(t *testing.T) { + dir := t.TempDir() + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + b := session.NewJSONLBackend(store) + + for i := 0; i < 12; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + b.TruncateHistory("s1", 3) + + // Runtime context remains truncated. + history := b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("got %d messages, want 3", len(history)) + } + + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + // Archive file should still retain full append-only history lines. + lineCount, err := countNonEmptyLines(filepath.Join(dir, "s1.jsonl")) + if err != nil { + t.Fatalf("countNonEmptyLines() error = %v", err) + } + if lineCount != 12 { + t.Fatalf("lineCount = %d, want 12", lineCount) + } +} + +func countNonEmptyLines(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + n := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) != "" { + n++ + } + } + if err := scanner.Err(); err != nil { + return 0, err + } + return n, nil +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go index a2e931010..f36f597d6 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -164,7 +164,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag return msgs, nil } -func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { +func (h *Handler) readJSONLSession(dir, sessionID string, includeArchived bool) (sessionFile, error) { sessionKey := picoSessionPrefix + sessionID base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) jsonlPath := base + ".jsonl" @@ -175,7 +175,13 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { return sessionFile{}, err } - messages, err := h.readSessionMessages(jsonlPath, meta.Skip) + skip := meta.Skip + if includeArchived { + // Web history APIs should expose the full transcript archive rather than + // the runtime context window that uses logical skip-truncation. + skip = 0 + } + messages, err := h.readSessionMessages(jsonlPath, skip) if err != nil { return sessionFile{}, err } @@ -406,7 +412,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { if !ok { continue } - sess, loadErr = h.readJSONLSession(dir, sessionID) + sess, loadErr = h.readJSONLSession(dir, sessionID, true) if loadErr == nil && isEmptySession(sess) { continue } @@ -419,6 +425,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { if jsonlSess, jsonlErr := h.readJSONLSession( dir, jsonlSessionID, + true, ); jsonlErr == nil && !isEmptySession(jsonlSess) { continue @@ -508,7 +515,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - sess, err := h.readJSONLSession(dir, sessionID) + sess, err := h.readJSONLSession(dir, sessionID, true) if err == nil && isEmptySession(sess) { err = os.ErrNotExist } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 9248c11b7..8a2314997 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -217,6 +217,72 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +func TestHandleGetSession_JSONLStorage_IncludesArchivedMessagesAfterTruncate(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-jsonl-archived" + for _, msg := range []providers.Message{ + {Role: "user", Content: "u1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "u2"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "u3"}, + {Role: "assistant", Content: "a3"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + if err := store.TruncateHistory(nil, sessionKey, 2); err != nil { + t.Fatalf("TruncateHistory() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Runtime history is logically truncated to the tail for context control. + runtimeMsgs, err := store.GetHistory(nil, sessionKey) + if err != nil { + t.Fatalf("GetHistory() error = %v", err) + } + if len(runtimeMsgs) != 2 { + t.Fatalf("len(runtimeMsgs) = %d, want 2", len(runtimeMsgs)) + } + + // Web API should still expose full archived transcript. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl-archived", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 6 { + t.Fatalf("len(resp.Messages) = %d, want 6", len(resp.Messages)) + } + if resp.Messages[0].Content != "u1" || resp.Messages[5].Content != "a3" { + t.Fatalf("unexpected transcript boundary: first=%q last=%q", resp.Messages[0].Content, resp.Messages[5].Content) + } +} + func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup()