fix(session): preserve archived history across summarize truncation

This commit is contained in:
Alix-007 2026-04-03 17:55:50 +08:00
parent bd56e10bb8
commit 979a89b3c2
4 changed files with 141 additions and 8 deletions

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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()