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
|
// Save persists session state. JSONL writes are already durable because
|
||||||
// immediately, the data is already durable. Save runs compaction to reclaim
|
// Add*/Set*/Truncate* fsync metadata/data on each write, so Save is a no-op.
|
||||||
// space from logically truncated messages (no-op when there are none).
|
//
|
||||||
|
// 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 {
|
func (b *JSONLBackend) Save(key string) error {
|
||||||
return b.store.Compact(context.Background(), key)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases resources held by the underlying store.
|
// Close releases resources held by the underlying store.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package session_test
|
package session_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/memory"
|
"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")
|
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
|
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
|
sessionKey := picoSessionPrefix + sessionID
|
||||||
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
||||||
jsonlPath := base + ".jsonl"
|
jsonlPath := base + ".jsonl"
|
||||||
|
|
@ -175,7 +175,13 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) {
|
||||||
return sessionFile{}, err
|
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 {
|
if err != nil {
|
||||||
return sessionFile{}, err
|
return sessionFile{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -406,7 +412,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sess, loadErr = h.readJSONLSession(dir, sessionID)
|
sess, loadErr = h.readJSONLSession(dir, sessionID, true)
|
||||||
if loadErr == nil && isEmptySession(sess) {
|
if loadErr == nil && isEmptySession(sess) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +425,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if jsonlSess, jsonlErr := h.readJSONLSession(
|
if jsonlSess, jsonlErr := h.readJSONLSession(
|
||||||
dir,
|
dir,
|
||||||
jsonlSessionID,
|
jsonlSessionID,
|
||||||
|
true,
|
||||||
); jsonlErr == nil &&
|
); jsonlErr == nil &&
|
||||||
!isEmptySession(jsonlSess) {
|
!isEmptySession(jsonlSess) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -508,7 +515,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.readJSONLSession(dir, sessionID)
|
sess, err := h.readJSONLSession(dir, sessionID, true)
|
||||||
if err == nil && isEmptySession(sess) {
|
if err == nil && isEmptySession(sess) {
|
||||||
err = os.ErrNotExist
|
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) {
|
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue