fix(session): preserve archived history across summarize truncation
This commit is contained in:
parent
bd56e10bb8
commit
979a89b3c2
4 changed files with 141 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue