2198 lines
80 KiB
Go
2198 lines
80 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
// touch sets a file's mtime to t. Used by the listing-order test so it
|
|
// doesn't have to sleep between Saves.
|
|
func touch(path string, t time.Time) error {
|
|
return os.Chtimes(path, t, t)
|
|
}
|
|
|
|
// TestSaveLoadRoundTrip is the contract `reasonix --resume` depends on: a
|
|
// session written to disk reloads byte-for-byte, including tool calls and
|
|
// reasoning content (which the model wants to keep across resumes for cache
|
|
// hits on thinking-mode providers).
|
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
|
s := NewSession("you are reasonix")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "find the bug"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant,
|
|
Content: "Let me check.",
|
|
ReasoningContent: "I should look at main.go first.",
|
|
ToolCalls: []provider.ToolCall{{
|
|
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
|
|
}},
|
|
})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_1",
|
|
Content: "package main\nfunc main() {}\n",
|
|
})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "It's fine."})
|
|
|
|
path := filepath.Join(t.TempDir(), "s.jsonl")
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got, want := len(loaded.Messages), len(s.Messages); got != want {
|
|
t.Fatalf("message count after round-trip = %d, want %d", got, want)
|
|
}
|
|
for i, m := range s.Messages {
|
|
if loaded.Messages[i].Role != m.Role {
|
|
t.Errorf("message %d role mismatch", i)
|
|
}
|
|
if loaded.Messages[i].Content != m.Content {
|
|
t.Errorf("message %d content mismatch", i)
|
|
}
|
|
if loaded.Messages[i].ReasoningContent != m.ReasoningContent {
|
|
t.Errorf("message %d reasoning mismatch", i)
|
|
}
|
|
if len(loaded.Messages[i].ToolCalls) != len(m.ToolCalls) {
|
|
t.Errorf("message %d tool_calls count mismatch", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveRedactsSecretsOnDisk(t *testing.T) {
|
|
secret := "sk-real-secret-value-123456"
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleTool,
|
|
Name: "bash",
|
|
ToolCallID: "call_1",
|
|
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
|
|
})
|
|
|
|
path := filepath.Join(t.TempDir(), "redacted.jsonl")
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read saved session: %v", err)
|
|
}
|
|
if strings.Contains(string(body), secret) {
|
|
t.Fatalf("session persisted raw secret:\n%s", body)
|
|
}
|
|
if !strings.Contains(string(body), "DEEPSEEK_API_KEY=sk-rea") {
|
|
t.Fatalf("session did not persist a masked credential marker:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestSaveRedactionKeepsSnapshotBaselineStable is the digest-consistency
|
|
// contract behind redact-on-save: because Redact is idempotent, a loaded
|
|
// (already-redacted) transcript re-saves as the exact same bytes, so
|
|
// SaveSnapshot must take the up-to-date path — no rewrite, no revision bump,
|
|
// and above all no bogus snapshot conflict / recovery fork (#6083's failure
|
|
// shape). Appending after the resave must land as a plain append.
|
|
func TestSaveRedactionKeepsSnapshotBaselineStable(t *testing.T) {
|
|
secret := "sk-real-secret-value-123456"
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleTool,
|
|
Name: "bash",
|
|
ToolCallID: "call_1",
|
|
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
|
|
})
|
|
|
|
path := filepath.Join(t.TempDir(), "stable.jsonl")
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("first SaveSnapshot: %v", err)
|
|
}
|
|
rev1, _, err := sessionContentRevision(path)
|
|
if err != nil {
|
|
t.Fatalf("read revision: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
// Re-saving the loaded transcript re-runs RedactMessages over content that
|
|
// is already masked; idempotence means identical bytes and the up-to-date
|
|
// skip, not a conflict or a revision bump.
|
|
if err := loaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("resave of loaded redacted session: %v", err)
|
|
}
|
|
rev2, _, err := sessionContentRevision(path)
|
|
if err != nil {
|
|
t.Fatalf("read revision after resave: %v", err)
|
|
}
|
|
if rev1 != rev2 {
|
|
t.Fatalf("no-op resave bumped revision %d -> %d; redaction broke digest stability", rev1, rev2)
|
|
}
|
|
|
|
// A continued conversation still append-saves cleanly on top.
|
|
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
|
if err := loaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("append snapshot after redacted round-trip: %v", err)
|
|
}
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if got := len(reloaded.Messages); got != 4 {
|
|
t.Fatalf("message count after append = %d, want 4", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveLoadLargeMessage(t *testing.T) {
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run it"})
|
|
// A bash result can exceed any line-buffer cap; Save must round-trip it.
|
|
big := strings.Repeat("x", 5*1024*1024)
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "c1", Content: big})
|
|
|
|
path := filepath.Join(t.TempDir(), "big.jsonl")
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession of a session with a >4MiB message: %v", err)
|
|
}
|
|
if len(loaded.Messages) != 3 {
|
|
t.Fatalf("message count = %d, want 3", len(loaded.Messages))
|
|
}
|
|
if loaded.Messages[2].Content != big {
|
|
t.Errorf("large content not round-tripped (got %d bytes, want %d)", len(loaded.Messages[2].Content), len(big))
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotRejectsStalePrefixOverwrite(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveSnapshot stale prefix err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 5 {
|
|
t.Fatalf("message count after stale snapshot = %d, want 5", got)
|
|
}
|
|
if got := loaded.Messages[4].Content; got != "two" {
|
|
t.Fatalf("last message after stale snapshot = %q, want %q", got, "two")
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAllowsAppendFromDiskPrefix(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
next := NewSession("sys")
|
|
next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := next.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 3 {
|
|
t.Fatalf("message count after append snapshot = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
// TestSaveSnapshotAppendsAcrossInterruptedToolCallTail is the mid-turn autosave
|
|
// shape from the field: a snapshot lands between an assistant tool call and its
|
|
// still-running result, so the transcript on disk ends with a dangling call
|
|
// that LoadSession answers with a fabricated placeholder. The live session then
|
|
// records the real result and keeps going. The next snapshot is a pure append
|
|
// over the bytes on disk and must land as one — not collide with the
|
|
// placeholder, misread the turn as divergence, and fork a recovery branch.
|
|
func TestSaveSnapshotAppendsAcrossInterruptedToolCallTail(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "Running it.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
|
})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
|
}
|
|
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Build passed."})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot after tool result: %v", err)
|
|
}
|
|
|
|
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("replay event log: %v", err)
|
|
}
|
|
if replay.damaged {
|
|
t.Fatal("event log damaged after appending over an interrupted tool tail")
|
|
}
|
|
if replay.records != 2 {
|
|
t.Fatalf("event log records = %d, want 2 (bootstrap replace + append)", replay.records)
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 5 {
|
|
t.Fatalf("message count after append snapshot = %d, want 5", got)
|
|
}
|
|
if got := loaded.Messages[3].Content; got != "ok" {
|
|
t.Fatalf("tool result after round-trip = %q, want %q", got, "ok")
|
|
}
|
|
if loaded.normalizedDirty {
|
|
t.Fatal("transcript still needs repair after appending the real tool result")
|
|
}
|
|
}
|
|
|
|
// TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail covers the
|
|
// same raw-prefix fallback when a multi-call assistant turn already has some
|
|
// tool results on disk. LoadSession fabricates placeholders only for the still
|
|
// unanswered calls; the live session later appends the real remaining results.
|
|
func TestSaveSnapshotAppendsAcrossPartiallyAnsweredMultiToolCallTail(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect and test"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "I will run a few checks.",
|
|
ToolCalls: []provider.ToolCall{
|
|
{ID: "call_read", Name: "read_file", Arguments: `{"path":"main.go"}`},
|
|
{ID: "call_test", Name: "bash", Arguments: `{"cmd":"go test ./..."}`},
|
|
{ID: "call_status", Name: "bash", Arguments: `{"cmd":"git status --short"}`},
|
|
},
|
|
})
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "package main"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("partial multi-tool SaveSnapshot: %v", err)
|
|
}
|
|
|
|
loadedPartial, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession partial: %v", err)
|
|
}
|
|
if !loadedPartial.normalizedDirty {
|
|
t.Fatal("partial multi-tool load should need placeholder repair")
|
|
}
|
|
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_test", Content: "ok"})
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_status", Content: "clean"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "All checks passed."})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot after remaining multi-tool results: %v", err)
|
|
}
|
|
|
|
events := readSessionEventsForTest(t, path)
|
|
if len(events) != 2 || events[1].Type != sessionEventTypeAppend {
|
|
t.Fatalf("events after multi-tool append = %+v, want trailing append", events)
|
|
}
|
|
if events[1].MessageIndex != 4 || len(events[1].Messages) != 3 {
|
|
t.Fatalf("multi-tool append event index=%d len=%d, want index 4 len 3", events[1].MessageIndex, len(events[1].Messages))
|
|
}
|
|
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("replay event log: %v", err)
|
|
}
|
|
if replay.damaged {
|
|
t.Fatal("event log damaged after appending remaining multi-tool results")
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession final: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 7 {
|
|
t.Fatalf("message count after multi-tool append = %d, want 7", got)
|
|
}
|
|
if got := loaded.Messages[4].Content; got != "ok" {
|
|
t.Fatalf("second tool result after round-trip = %q, want ok", got)
|
|
}
|
|
if got := loaded.Messages[5].Content; got != "clean" {
|
|
t.Fatalf("third tool result after round-trip = %q, want clean", got)
|
|
}
|
|
if loaded.normalizedDirty {
|
|
t.Fatal("multi-tool transcript still needs repair after real results landed")
|
|
}
|
|
}
|
|
|
|
// TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp covers the turn that
|
|
// stays interrupted (cancel, crash recovery with nothing new in memory):
|
|
// re-snapshotting the exact bytes on disk must be a no-op, not a stale-prefix
|
|
// conflict against the placeholder the load-time repair fabricated.
|
|
func TestSaveSnapshotUnchangedInterruptedToolCallTailIsNoOp(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "Running it.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
|
})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
|
}
|
|
logBefore, err := os.ReadFile(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile event log: %v", err)
|
|
}
|
|
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot unchanged: %v", err)
|
|
}
|
|
logAfter, err := os.ReadFile(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile event log after no-op snapshot: %v", err)
|
|
}
|
|
if string(logBefore) != string(logAfter) {
|
|
t.Fatal("no-op snapshot rewrote the event log")
|
|
}
|
|
if revision, _, err := sessionContentRevision(path); err != nil || revision != 1 {
|
|
t.Fatalf("revision after no-op snapshot = %d (err %v), want 1", revision, err)
|
|
}
|
|
}
|
|
|
|
// TestSaveRewriteOwnedAcrossInterruptedToolCallTail: compaction rewrites the
|
|
// in-memory history while the transcript on disk still ends with the dangling
|
|
// call a mid-turn snapshot left behind. Ownership is anchored on the raw bytes
|
|
// this session wrote; the placeholder fabricated on load must not revoke it.
|
|
func TestSaveRewriteOwnedAcrossInterruptedToolCallTail(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "Running it.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
|
})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
|
}
|
|
|
|
s.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "[compacted] run the build"},
|
|
})
|
|
if err := s.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite after compaction: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 2 {
|
|
t.Fatalf("message count after owned rewrite = %d, want 2", got)
|
|
}
|
|
if got := loaded.Messages[1].Content; got != "[compacted] run the build" {
|
|
t.Fatalf("compacted message after round-trip = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable: a session resumed
|
|
// from an interrupted tool tail carries the load-time repair in memory, so its
|
|
// transcript is one message longer than what the event log replays. The next
|
|
// snapshot must not take the append shortcut with that inflated index — the
|
|
// chain-broken append event would be discarded on replay, silently dropping
|
|
// the whole new turn from disk. It must fall back to a full rewrite that
|
|
// persists the repair and the new turn together.
|
|
func TestSaveSnapshotAfterDirtyResumeKeepsEventChainReplayable(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "Running it.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"make"}`}},
|
|
})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("mid-turn SaveSnapshot: %v", err)
|
|
}
|
|
|
|
// Crash + reopen: the resume load answers the dangling call with a
|
|
// placeholder, then the user runs another turn.
|
|
resumed, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession resume: %v", err)
|
|
}
|
|
if !resumed.normalizedDirty {
|
|
t.Fatal("resume load should carry a pending repair for the dangling call")
|
|
}
|
|
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "try again"})
|
|
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
|
if err := resumed.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot after dirty resume: %v", err)
|
|
}
|
|
|
|
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("replay event log: %v", err)
|
|
}
|
|
if replay.damaged {
|
|
t.Fatal("event log chain broken by the post-resume snapshot")
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession reload: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 6 {
|
|
t.Fatalf("message count after post-resume snapshot = %d, want 6", got)
|
|
}
|
|
if got := loaded.Messages[3].ToolCallID; got != "call_1" {
|
|
t.Fatalf("placeholder tool result not persisted; message 3 tool_call_id = %q", got)
|
|
}
|
|
if got := loaded.Messages[5].Content; got != "done" {
|
|
t.Fatalf("new turn after round-trip = %q, want %q", got, "done")
|
|
}
|
|
}
|
|
|
|
// TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair covers a
|
|
// same-length load-time repair: truncated tool-call JSON is fixed in memory, but
|
|
// appending against the repaired view would leave the broken arguments on disk.
|
|
// The snapshot must rewrite so the repair and new turn persist together.
|
|
func TestSaveSnapshotAfterDirtyResumeWithTruncatedToolArgsPersistsRepair(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run tests"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant,
|
|
Content: "Running tests.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "bash", Arguments: `{"cmd":"go test ./...`}},
|
|
})
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot with truncated args: %v", err)
|
|
}
|
|
|
|
resumed, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession resume: %v", err)
|
|
}
|
|
if !resumed.normalizedDirty {
|
|
t.Fatal("resume load should carry a pending repair for truncated tool arguments")
|
|
}
|
|
const repairedArgs = `{"cmd":"go test ./..."}`
|
|
if got := resumed.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
|
|
t.Fatalf("repaired args on resume = %q, want %q", got, repairedArgs)
|
|
}
|
|
|
|
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "summarize"})
|
|
resumed.Add(provider.Message{Role: provider.RoleAssistant, Content: "Tests passed."})
|
|
if err := resumed.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot after truncated-args resume: %v", err)
|
|
}
|
|
|
|
events := readSessionEventsForTest(t, path)
|
|
if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "snapshot" {
|
|
t.Fatalf("events after truncated-args repair = %+v, want trailing snapshot replace", events)
|
|
}
|
|
replay, err := replaySessionEventLog(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("replay event log: %v", err)
|
|
}
|
|
if replay.damaged {
|
|
t.Fatal("event log damaged after truncated-args repair rewrite")
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession reload: %v", err)
|
|
}
|
|
if loaded.normalizedDirty {
|
|
t.Fatal("truncated-args repair was not persisted")
|
|
}
|
|
if got := loaded.Messages[2].ToolCalls[0].Arguments; got != repairedArgs {
|
|
t.Fatalf("persisted args = %q, want %q", got, repairedArgs)
|
|
}
|
|
if got := loaded.Messages[5].Content; got != "Tests passed." {
|
|
t.Fatalf("new turn after round-trip = %q, want %q", got, "Tests passed.")
|
|
}
|
|
}
|
|
|
|
// TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot: the
|
|
// recovery-needed check must also judge coverage against the pre-repair bytes.
|
|
// Here the stored transcript equals the snapshot exactly, but normalization
|
|
// backfills an empty tool-call name on load; that repair must not make the
|
|
// disk look like it fails to cover the snapshot and fork a pointless recovery.
|
|
func TestSaveRecoveryBranchNotNeededWhenRawTranscriptCoversSnapshot(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "run the build"})
|
|
s.Add(provider.Message{
|
|
Role: provider.RoleAssistant, Content: "Running it.",
|
|
ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "", Arguments: `{"cmd":"make"}`}},
|
|
})
|
|
s.Add(provider.Message{Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "ok"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
_, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if !errors.Is(err, ErrSessionRecoveryNotNeeded) {
|
|
t.Fatalf("SaveRecoveryBranch err = %v, want ErrSessionRecoveryNotNeeded", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAppendsWithoutReplacingPrefixFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
before, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("Stat before append: %v", err)
|
|
}
|
|
|
|
next := NewSession("sys")
|
|
next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := next.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append: %v", err)
|
|
}
|
|
after, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("Stat after append: %v", err)
|
|
}
|
|
if !os.SameFile(before, after) {
|
|
t.Fatal("SaveSnapshot replaced the session file; want append-in-place for disk-prefix snapshots")
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAppendsToEventLogWithoutChangingCheckpoint(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := base.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
checkpointBefore, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile checkpoint before append: %v", err)
|
|
}
|
|
|
|
next, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession base: %v", err)
|
|
}
|
|
next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := next.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append: %v", err)
|
|
}
|
|
|
|
checkpointAfter, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile checkpoint after append: %v", err)
|
|
}
|
|
if string(checkpointAfter) != string(checkpointBefore) {
|
|
t.Fatalf("checkpoint changed after append-only snapshot:\nbefore=%s\nafter=%s", checkpointBefore, checkpointAfter)
|
|
}
|
|
events := readSessionEventsForTest(t, path)
|
|
if len(events) != 2 {
|
|
t.Fatalf("event count = %d, want replace + append", len(events))
|
|
}
|
|
if events[0].Type != sessionEventTypeReplace || events[1].Type != sessionEventTypeAppend {
|
|
t.Fatalf("event types = %q, %q; want replace, append", events[0].Type, events[1].Type)
|
|
}
|
|
if events[1].MessageIndex != 2 || len(events[1].Messages) != 1 || events[1].Messages[0].Content != "one" {
|
|
t.Fatalf("append event = %+v, want assistant suffix at index 2", events[1])
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession after append: %v", err)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "one" {
|
|
t.Fatalf("loaded tail = %q, want one", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteAppendsReplaceEventAndRefreshesCheckpoint(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := base.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession base: %v", err)
|
|
}
|
|
loaded.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "rewound"},
|
|
})
|
|
if err := loaded.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite: %v", err)
|
|
}
|
|
// Rewrites refresh the compatibility checkpoint so direct .jsonl readers
|
|
// and older binaries stay bounded-stale instead of frozen at first save.
|
|
anchor, err := loadSessionMessagesFromJSONL(path)
|
|
if err != nil {
|
|
t.Fatalf("read checkpoint after rewrite: %v", err)
|
|
}
|
|
if len(anchor) != 2 || anchor[1].Content != "rewound" {
|
|
t.Fatalf("checkpoint after rewrite = %+v, want refreshed rewound transcript", anchor)
|
|
}
|
|
events := readSessionEventsForTest(t, path)
|
|
if len(events) != 2 || events[1].Type != sessionEventTypeReplace || events[1].Reason != "rewrite" {
|
|
t.Fatalf("events after rewrite = %+v, want trailing rewrite replace", events)
|
|
}
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession after rewrite: %v", err)
|
|
}
|
|
if len(reloaded.Messages) != 2 || reloaded.Messages[1].Content != "rewound" {
|
|
t.Fatalf("replayed rewrite messages = %+v", reloaded.Messages)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotMigratesLegacyJSONLToEventLog(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "legacy.jsonl")
|
|
if err := os.WriteFile(path, []byte(`{"role":"system","content":"sys"}`+"\n"+`{"role":"user","content":"legacy"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write legacy jsonl: %v", err)
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession legacy: %v", err)
|
|
}
|
|
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "migrated"})
|
|
if err := loaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot legacy append: %v", err)
|
|
}
|
|
events := readSessionEventsForTest(t, path)
|
|
if len(events) != 1 || events[0].Type != sessionEventTypeReplace {
|
|
t.Fatalf("legacy migration events = %+v, want one replace seed", events)
|
|
}
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession migrated: %v", err)
|
|
}
|
|
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "migrated" {
|
|
t.Fatalf("migrated tail = %q, want migrated", got)
|
|
}
|
|
if _, err := os.Stat(SessionEventIndexPath(path)); err != nil {
|
|
t.Fatalf("event index missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAllowsAppendAfterSystemPromptRefresh(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("old sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
next := NewSession("new sys")
|
|
next.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := next.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot after system refresh: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 3 {
|
|
t.Fatalf("message count after system refresh append = %d, want 3", got)
|
|
}
|
|
if got := loaded.Messages[0].Content; got != "new sys" {
|
|
t.Fatalf("system prompt after refresh = %q, want %q", got, "new sys")
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotRecordsRevisionAndMetaUpdatesPreserveIt(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := base.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
meta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.Revision != 1 || meta.ContentDigest == "" || meta.WriterID == "" {
|
|
t.Fatalf("base persistence meta = %+v, want revision/digest/writer", meta)
|
|
}
|
|
|
|
if err := UpdateSessionMeta(path, "model-a", "first", 1, true); err != nil {
|
|
t.Fatalf("UpdateSessionMeta: %v", err)
|
|
}
|
|
refreshed, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta refreshed ok=%v err=%v", ok, err)
|
|
}
|
|
if refreshed.Revision != meta.Revision || refreshed.ContentDigest != meta.ContentDigest || refreshed.WriterID != meta.WriterID {
|
|
t.Fatalf("listing meta update changed persistence fields: before=%+v after=%+v", meta, refreshed)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := loaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append: %v", err)
|
|
}
|
|
advanced, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
|
}
|
|
if advanced.Revision != refreshed.Revision+1 {
|
|
t.Fatalf("revision after append = %d, want %d", advanced.Revision, refreshed.Revision+1)
|
|
}
|
|
if advanced.ContentDigest == refreshed.ContentDigest {
|
|
t.Fatalf("content digest did not change after append: %q", advanced.ContentDigest)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotSameContentSkipsRevisionBump(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
before, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot same content: %v", err)
|
|
}
|
|
after, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta after no-op ok=%v err=%v", ok, err)
|
|
}
|
|
if after.Revision != before.Revision || after.ContentDigest != before.ContentDigest || after.WriterID != before.WriterID {
|
|
t.Fatalf("same-content snapshot changed persistence meta: before=%+v after=%+v", before, after)
|
|
}
|
|
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append after no-op: %v", err)
|
|
}
|
|
advanced, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
|
}
|
|
if advanced.Revision != before.Revision+1 {
|
|
t.Fatalf("revision after append = %d, want %d", advanced.Revision, before.Revision+1)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotSameContentByOtherRuntimeKeepsClonedBaselineWritable(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
resumed, ok := loaded.CloneWithMessagesIfCompatible(loaded.Snapshot())
|
|
if !ok {
|
|
t.Fatal("expected compatible clone")
|
|
}
|
|
|
|
// Another runtime autosaves the identical transcript (e.g. a shutdown
|
|
// snapshot of an idle tab). It must not bump the revision, or the resumed
|
|
// clone's baseline goes stale and its next append is misread as a
|
|
// stale-runtime conflict.
|
|
other, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession other: %v", err)
|
|
}
|
|
if err := other.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot other same content: %v", err)
|
|
}
|
|
|
|
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
|
|
if err := resumed.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot append after same-content autosave elsewhere: %v", err)
|
|
}
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession appended: %v", err)
|
|
}
|
|
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "next" {
|
|
t.Fatalf("tail after append = %q, want next", got)
|
|
}
|
|
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
|
t.Fatalf("recovery branches after append = %v err=%v, want none", matches, err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
staleBaseline := s.persistState(path)
|
|
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot prefix append: %v", err)
|
|
}
|
|
prefixMeta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
|
|
}
|
|
if prefixMeta.Revision == staleBaseline.revision {
|
|
t.Fatalf("prefix revision did not advance: %d", prefixMeta.Revision)
|
|
}
|
|
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
|
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession appended: %v", err)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
|
|
t.Fatalf("tail after stale-baseline append = %q, want two", got)
|
|
}
|
|
advancedMeta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
|
}
|
|
if advancedMeta.Revision != prefixMeta.Revision+1 {
|
|
t.Fatalf("revision after stale-baseline append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
|
|
}
|
|
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
|
t.Fatalf("recovery branches after stale-baseline append = %v err=%v, want none", matches, err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys v1")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
staleBaseline := s.persistState(path)
|
|
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot prefix append: %v", err)
|
|
}
|
|
prefixMeta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta prefix ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
// A resume swapped the system prompt, then the turn appended a message —
|
|
// while the persistence baseline still points at the first save.
|
|
msgs := s.Snapshot()
|
|
msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
|
|
msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
|
|
s.Replace(msgs)
|
|
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession appended: %v", err)
|
|
}
|
|
if got := loaded.Messages[0].Content; got != "sys v2" {
|
|
t.Fatalf("system after compatible append = %q, want sys v2", got)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "two" {
|
|
t.Fatalf("tail after compatible append = %q, want two", got)
|
|
}
|
|
advancedMeta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
|
}
|
|
if advancedMeta.Revision != prefixMeta.Revision+1 {
|
|
t.Fatalf("revision after compatible append = %d, want %d", advancedMeta.Revision, prefixMeta.Revision+1)
|
|
}
|
|
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
|
t.Fatalf("recovery branches after compatible append = %v err=%v, want none", matches, err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotRefusesStaleBaselineAppendOverRewoundTranscript(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot extend: %v", err)
|
|
}
|
|
|
|
// Another runtime rewinds the transcript below this session's baseline
|
|
// (e.g. a cancelled turn truncated the partial assistant reply).
|
|
other, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession other: %v", err)
|
|
}
|
|
other.Replace(other.Snapshot()[:2])
|
|
if err := other.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite rewind: %v", err)
|
|
}
|
|
|
|
// Appending from the stale baseline would resurrect the rewound suffix.
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
|
if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveSnapshot over rewound transcript err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession after refused append: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 2 {
|
|
t.Fatalf("messages after refused append = %d, want rewound 2", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteAllowsOwnedRewriteAfterLedgerReset(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial turn"})
|
|
if err := s.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot base: %v", err)
|
|
}
|
|
// The meta sidecar (revision ledger) is lost — e.g. swept by a cleanup
|
|
// that deleted session-adjacent files. The transcript itself is intact.
|
|
if err := os.Remove(BranchMetaPath(path)); err != nil {
|
|
t.Fatalf("remove sidecar: %v", err)
|
|
}
|
|
|
|
// A cancelled turn strips the partial reply and flushes via SaveRewrite.
|
|
msgs := s.Snapshot()
|
|
s.Replace(msgs[:len(msgs)-1])
|
|
if err := s.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite after ledger reset: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession truncated: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 2 {
|
|
t.Fatalf("messages after owned rewrite = %d, want 2", got)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "first" {
|
|
t.Fatalf("tail after owned rewrite = %q, want first", got)
|
|
}
|
|
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
|
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotStillPersistsNormalizedRepair(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
mal := NewSession("sys")
|
|
mal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
// Unanswered tool call: LoadSession backfills a placeholder result, so the
|
|
// loaded history digests equal to itself while the on-disk bytes differ.
|
|
mal.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
|
mal.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
|
if err := mal.Save(path); err != nil {
|
|
t.Fatalf("Save malformed: %v", err)
|
|
}
|
|
base, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta base ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if !loaded.normalizedDirty {
|
|
t.Fatal("fixture did not trigger a load-time repair; adjust the malformed history")
|
|
}
|
|
if err := loaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot repaired history: %v", err)
|
|
}
|
|
repaired, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta repaired ok=%v err=%v", ok, err)
|
|
}
|
|
if repaired.Revision != base.Revision+1 {
|
|
t.Fatalf("revision after repair save = %d, want %d", repaired.Revision, base.Revision+1)
|
|
}
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession repaired: %v", err)
|
|
}
|
|
if reloaded.normalizedDirty {
|
|
t.Fatal("repair did not persist: reloaded session is still normalized-dirty")
|
|
}
|
|
|
|
// With the repair on disk, the same snapshot is now a true no-op.
|
|
if err := reloaded.SaveSnapshot(path); err != nil {
|
|
t.Fatalf("SaveSnapshot post-repair: %v", err)
|
|
}
|
|
final, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta final ok=%v err=%v", ok, err)
|
|
}
|
|
if final.Revision != repaired.Revision {
|
|
t.Fatalf("post-repair no-op bumped revision: %d, want %d", final.Revision, repaired.Revision)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("new sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("old sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveSnapshot stale after system refresh err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 4 {
|
|
t.Fatalf("message count after stale system refresh snapshot = %d, want 4", got)
|
|
}
|
|
if got := loaded.Messages[3].Content; got != "second" {
|
|
t.Fatalf("last message after stale system refresh snapshot = %q, want %q", got, "second")
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
stale, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession stale: %v", err)
|
|
}
|
|
// Another runtime healed the ledger over identical content: the revision
|
|
// advanced under a foreign writer id, but the recorded digest still
|
|
// describes the exact bytes this session loaded and owns.
|
|
meta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
|
}
|
|
meta.Revision++
|
|
meta.WriterID = "other-writer"
|
|
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
|
t.Fatalf("bump revision: %v", err)
|
|
}
|
|
|
|
stale.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "summarized first"},
|
|
})
|
|
if err := stale.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite over same-content stamp: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
|
|
t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
|
|
}
|
|
advanced, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
|
|
}
|
|
if advanced.Revision != meta.Revision+1 {
|
|
t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
|
|
}
|
|
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
|
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
base := NewSession("sys")
|
|
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := base.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
stale, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession stale: %v", err)
|
|
}
|
|
// A foreign stamp whose digest disagrees with the on-disk transcript is
|
|
// the aftermath of a save whose bytes and record split — the transcript
|
|
// cannot be attributed, so the rewrite must fall to the conflict path.
|
|
meta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
|
}
|
|
meta.Revision++
|
|
meta.WriterID = "other-writer"
|
|
meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
|
|
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
|
t.Fatalf("stamp foreign digest: %v", err)
|
|
}
|
|
|
|
stale.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "summarized first"},
|
|
})
|
|
err = stale.SaveRewrite(path)
|
|
if !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
var conflict *SessionSnapshotConflictError
|
|
if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {
|
|
t.Fatalf("conflict = %+v, want diverged revision conflict", conflict)
|
|
}
|
|
if conflict.BaseRevision != meta.Revision-1 || conflict.DiskRevision != meta.Revision {
|
|
t.Fatalf("conflict revisions = base %d disk %d, want %d/%d",
|
|
conflict.BaseRevision, conflict.DiskRevision, meta.Revision-1, meta.Revision)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "one" {
|
|
t.Fatalf("tail after rejected rewrite = %q, want one", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveSnapshotRejectsOwnedNonPrefixRewrite(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
s.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "summarized first"},
|
|
})
|
|
if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveSnapshot owned rewrite err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 3 {
|
|
t.Fatalf("message count after rejected snapshot rewrite = %d, want 3", got)
|
|
}
|
|
if got := loaded.Messages[2].Content; got != "one" {
|
|
t.Fatalf("last message after rejected snapshot rewrite = %q, want %q", got, "one")
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteAllowsOwnedNonPrefixRewrite(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
s.Replace([]provider.Message{
|
|
{Role: provider.RoleSystem, Content: "sys"},
|
|
{Role: provider.RoleUser, Content: "summarized first"},
|
|
})
|
|
if err := s.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite owned rewrite: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 2 {
|
|
t.Fatalf("message count after rewrite = %d, want 2", got)
|
|
}
|
|
if got := loaded.Messages[1].Content; got != "summarized first" {
|
|
t.Fatalf("rewritten content = %q, want %q", got, "summarized first")
|
|
}
|
|
}
|
|
|
|
func TestCloneWithMessagesPreservesRewriteBaseline(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("old sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
|
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
msgs := loaded.Snapshot()
|
|
msgs[0].Content = "new sys"
|
|
msgs[3].Content = "[elided tool result]"
|
|
resumed := loaded.CloneWithMessages(msgs)
|
|
if err := resumed.SaveRewrite(path); err != nil {
|
|
t.Fatalf("SaveRewrite cloned resume rewrite: %v", err)
|
|
}
|
|
|
|
reloaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession rewritten: %v", err)
|
|
}
|
|
if got := reloaded.Messages[0].Content; got != "new sys" {
|
|
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
|
|
}
|
|
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
|
|
t.Fatalf("tool result after rewrite = %q, want elided", got)
|
|
}
|
|
}
|
|
|
|
func TestCloneWithMessagesIfCompatibleRejectsHistoryChanges(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
s := NewSession("old sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatalf("Save base: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
systemOnly := loaded.Snapshot()
|
|
systemOnly[0].Content = "new sys"
|
|
if _, ok := loaded.CloneWithMessagesIfCompatible(systemOnly); !ok {
|
|
t.Fatal("system-only change should be compatible")
|
|
}
|
|
|
|
changed := loaded.Snapshot()
|
|
changed[2].Content = "rewritten assistant"
|
|
if _, ok := loaded.CloneWithMessagesIfCompatible(changed); ok {
|
|
t.Fatal("non-system history change should not preserve baseline")
|
|
}
|
|
}
|
|
|
|
func TestSaveRewriteRejectsStalePrefixOverwrite(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
if err := stale.SaveRewrite(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveRewrite stale err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
|
|
loaded, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession: %v", err)
|
|
}
|
|
if got := len(loaded.Messages); got != 4 {
|
|
t.Fatalf("message count after stale rewrite = %d, want 4", got)
|
|
}
|
|
if got := loaded.Messages[3].Content; got != "second" {
|
|
t.Fatalf("last message after stale rewrite = %q, want %q", got, "second")
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchPersistsDivergedSnapshot(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
|
if err := stale.SaveSnapshot(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
|
t.Fatalf("SaveSnapshot stale err = %v, want ErrSessionSnapshotConflict", err)
|
|
}
|
|
|
|
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch: %v", err)
|
|
}
|
|
if info.Path == "" || info.Path == path {
|
|
t.Fatalf("recovery path = %q, want distinct path", info.Path)
|
|
}
|
|
if info.Turns != 2 || info.Preview != "first" {
|
|
t.Fatalf("recovery preview/turns = %q/%d, want first/2", info.Preview, info.Turns)
|
|
}
|
|
recovered, err := LoadSession(info.Path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession recovery: %v", err)
|
|
}
|
|
if got := recovered.Messages[len(recovered.Messages)-1].Content; got != "local second" {
|
|
t.Fatalf("recovery tail = %q, want local second", got)
|
|
}
|
|
original, err := LoadSession(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession original: %v", err)
|
|
}
|
|
if got := original.Messages[len(original.Messages)-1].Content; got != "disk second" {
|
|
t.Fatalf("original tail = %q, want disk second", got)
|
|
}
|
|
meta, ok, err := LoadBranchMeta(info.Path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
|
}
|
|
if !meta.Recovered || meta.ParentID != BranchID(path) || meta.Name != RecoveryBranchDefaultName {
|
|
t.Fatalf("recovery meta = %+v, want recovered parent/name", meta)
|
|
}
|
|
if meta.RecoveryDigest == "" || meta.SchemaVersion != BranchMetaCountsVersion {
|
|
t.Fatalf("recovery digest/schema = %q/%d", meta.RecoveryDigest, meta.SchemaVersion)
|
|
}
|
|
if meta.Revision != 1 || meta.ContentDigest != meta.RecoveryDigest || meta.WriterID == "" {
|
|
t.Fatalf("recovery persistence meta = %+v, want revision/content digest/writer", meta)
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
if _, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path}); !errors.Is(err, ErrSessionRecoveryNotNeeded) {
|
|
t.Fatalf("SaveRecoveryBranch stale prefix err = %v, want ErrSessionRecoveryNotNeeded", err)
|
|
}
|
|
}
|
|
|
|
func divergedSessionPair(t *testing.T, dir, name string) (string, *Session) {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
|
|
return path, stale
|
|
}
|
|
|
|
func stampRecoveryMeta(t *testing.T, path string, depth int) {
|
|
t.Helper()
|
|
meta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
|
}
|
|
meta.Recovered = true
|
|
meta.RecoveryDepth = depth
|
|
if err := SaveBranchMeta(path, meta); err != nil {
|
|
t.Fatalf("SaveBranchMeta: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchStampsAndCapsChainDepth(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Forking from a normal session stamps depth 1.
|
|
path, stale := divergedSessionPair(t, dir, "session.jsonl")
|
|
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch: %v", err)
|
|
}
|
|
if info.Meta.RecoveryDepth != 1 {
|
|
t.Fatalf("first fork depth = %d, want 1", info.Meta.RecoveryDepth)
|
|
}
|
|
|
|
// Forking from a recovery branch increments the chain depth.
|
|
deeper, staleDeeper := divergedSessionPair(t, dir, "deeper.jsonl")
|
|
stampRecoveryMeta(t, deeper, 1)
|
|
info, err = staleDeeper.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: deeper})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch from depth 1: %v", err)
|
|
}
|
|
if info.Meta.RecoveryDepth != 2 {
|
|
t.Fatalf("nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
|
|
}
|
|
|
|
// A legacy recovery meta without the depth field counts as depth 1.
|
|
legacy, staleLegacy := divergedSessionPair(t, dir, "legacy.jsonl")
|
|
stampRecoveryMeta(t, legacy, 0)
|
|
info, err = staleLegacy.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: legacy})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch from legacy recovery: %v", err)
|
|
}
|
|
if info.Meta.RecoveryDepth != 2 {
|
|
t.Fatalf("legacy nested fork depth = %d, want 2", info.Meta.RecoveryDepth)
|
|
}
|
|
|
|
// A parent at the cap refuses to fork deeper.
|
|
capped, staleCapped := divergedSessionPair(t, dir, "capped.jsonl")
|
|
stampRecoveryMeta(t, capped, SessionRecoveryMaxDepth)
|
|
if _, err := staleCapped.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: capped}); !errors.Is(err, ErrSessionRecoveryDepthExceeded) {
|
|
t.Fatalf("SaveRecoveryBranch at cap err = %v, want ErrSessionRecoveryDepthExceeded", err)
|
|
}
|
|
forks, err := filepath.Glob(filepath.Join(dir, "capped-recovery-*.jsonl"))
|
|
if err != nil {
|
|
t.Fatalf("glob: %v", err)
|
|
}
|
|
if len(forks) != 0 {
|
|
t.Fatalf("capped parent still forked: %v", forks)
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchDedupesByDigest(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
|
|
first, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("first SaveRecoveryBranch: %v", err)
|
|
}
|
|
second, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("second SaveRecoveryBranch: %v", err)
|
|
}
|
|
if second.Path != first.Path || !second.Existing {
|
|
t.Fatalf("second recovery = %+v, want existing same path %q", second, first.Path)
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchCompactsLongParentFilename(t *testing.T) {
|
|
dir := t.TempDir()
|
|
parentID := strings.Repeat("longparent-", 22)
|
|
path := filepath.Join(dir, parentID+".jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
stale := NewSession("sys")
|
|
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
|
|
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch: %v", err)
|
|
}
|
|
base := filepath.Base(info.Path)
|
|
if len(base) > 140 {
|
|
t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
|
|
}
|
|
for _, suffix := range []string{".lock", ".lease.lock", ".lease.json", ".meta"} {
|
|
if len(base+suffix) > 255 {
|
|
t.Fatalf("recovery sidecar basename %q length = %d, want <= 255", base+suffix, len(base+suffix))
|
|
}
|
|
}
|
|
meta, ok, err := LoadBranchMeta(info.Path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.ParentID != BranchID(path) {
|
|
t.Fatalf("recovery parent = %q, want original branch id", meta.ParentID)
|
|
}
|
|
}
|
|
|
|
func TestSaveRecoveryBranchDoesNotCascadeRecoveryFilename(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "session.jsonl")
|
|
current := NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
|
|
if err := current.Save(path); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
local := NewSession("sys")
|
|
local.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
local.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
|
|
first, err := local.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
|
|
if err != nil {
|
|
t.Fatalf("first SaveRecoveryBranch: %v", err)
|
|
}
|
|
|
|
recoveryDisk, err := LoadSession(first.Path)
|
|
if err != nil {
|
|
t.Fatalf("LoadSession recovery: %v", err)
|
|
}
|
|
recoveryDisk.Add(provider.Message{Role: provider.RoleUser, Content: "disk follow-up"})
|
|
if err := recoveryDisk.SaveSnapshot(first.Path); err != nil {
|
|
t.Fatalf("SaveSnapshot recovery disk: %v", err)
|
|
}
|
|
recoveryLocal := NewSession("sys")
|
|
recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
recoveryLocal.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
|
|
recoveryLocal.Add(provider.Message{Role: provider.RoleUser, Content: "local follow-up"})
|
|
|
|
second, err := recoveryLocal.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: first.Path})
|
|
if err != nil {
|
|
t.Fatalf("second SaveRecoveryBranch: %v", err)
|
|
}
|
|
base := filepath.Base(second.Path)
|
|
if count := strings.Count(base, "-recovery-"); count != 1 {
|
|
t.Fatalf("recovery basename = %q, contains %d recovery markers, want 1", base, count)
|
|
}
|
|
if len(base) > 140 {
|
|
t.Fatalf("recovery basename length = %d (%q), want bounded", len(base), base)
|
|
}
|
|
}
|
|
|
|
func TestReconcileSessionSidecarsRemovesUnlockedArtifacts(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
|
|
if err := os.WriteFile(sidecar, []byte("{}\n"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", sidecar, err)
|
|
}
|
|
}
|
|
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("session transcript removed: %v", err)
|
|
}
|
|
for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
|
|
if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
|
|
t.Fatalf("%s exists after sidecar cleanup (err=%v)", sidecar, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcileSessionSidecarsKeepsLiveLocks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unlock, err := lockSessionFile(path)
|
|
if err != nil {
|
|
t.Fatalf("lockSessionFile: %v", err)
|
|
}
|
|
defer unlock()
|
|
lease, err := TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease: %v", err)
|
|
}
|
|
defer lease.Release()
|
|
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
|
}
|
|
for _, sidecar := range []string{path + ".lock", path + ".lease.lock", path + ".lease.json"} {
|
|
if _, err := os.Stat(sidecar); err != nil {
|
|
t.Fatalf("%s missing while lock is live: %v", sidecar, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReconcileSessionSidecarsKeepsFlockOnlyLocks proves the file lock alone
|
|
// protects a writer from cleanup: CLI-style savers hold the .lock flock while
|
|
// writing without ever taking a session lease.
|
|
func TestReconcileSessionSidecarsKeepsFlockOnlyLocks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "session.jsonl")
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unlock, err := lockSessionFile(path)
|
|
if err != nil {
|
|
t.Fatalf("lockSessionFile: %v", err)
|
|
}
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
|
}
|
|
if _, err := os.Stat(path + ".lock"); err != nil {
|
|
t.Fatalf(".lock removed while a lock-only writer holds it: %v", err)
|
|
}
|
|
unlock()
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars after unlock: %v", err)
|
|
}
|
|
if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
|
|
t.Fatalf(".lock survived cleanup after release (err=%v)", err)
|
|
}
|
|
}
|
|
|
|
// TestReconcileSessionSidecarsRenamesOverlongSessionFiles covers the
|
|
// migration for transcripts left behind by the unbounded recovery cascade
|
|
// (#5923): names so long their lock/lease sidecars could not be created. The
|
|
// conversation bytes must survive under a bounded name, branch meta must move
|
|
// with its ID rewritten, and children must be re-parented onto the new ID.
|
|
func TestReconcileSessionSidecarsRenamesOverlongSessionFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
longID := strings.Repeat("p", 240) // 246-byte basename: .lock fits, .lease.lock does not
|
|
hugeID := strings.Repeat("q", 248) // 254-byte basename: no sidecar fits at all
|
|
oldLong := filepath.Join(dir, longID+".jsonl")
|
|
oldHuge := filepath.Join(dir, hugeID+".jsonl")
|
|
content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
|
|
for _, p := range []string{oldLong, oldHuge} {
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(oldLong+".lock", []byte("{}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveBranchMeta(oldLong, BranchMeta{Name: "长会话", ParentID: "root-branch"}); err != nil {
|
|
t.Fatalf("SaveBranchMeta: %v", err)
|
|
}
|
|
childPath := filepath.Join(dir, "child.jsonl")
|
|
if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
|
|
t.Fatalf("SaveBranchMeta child: %v", err)
|
|
}
|
|
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
|
}
|
|
|
|
for _, gone := range []string{oldLong, oldHuge, oldLong + ".lock", oldLong + ".meta"} {
|
|
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
|
t.Fatalf("%s still present after rename (err=%v)", filepath.Base(gone), err)
|
|
}
|
|
}
|
|
newLongID := recoveryParentStem(longID)
|
|
newLong := filepath.Join(dir, newLongID+".jsonl")
|
|
newHuge := filepath.Join(dir, recoveryParentStem(hugeID)+".jsonl")
|
|
for _, p := range []string{newLong, newHuge} {
|
|
if base := filepath.Base(p); len(base) > maxSessionBasenameBytes {
|
|
t.Fatalf("renamed basename %q length %d exceeds bound %d", base, len(base), maxSessionBasenameBytes)
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatalf("read renamed transcript: %v", err)
|
|
}
|
|
if string(b) != content {
|
|
t.Fatalf("transcript content changed by rename: %q", b)
|
|
}
|
|
}
|
|
meta, ok, err := LoadBranchMeta(newLong)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta renamed ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.ID != newLongID {
|
|
t.Fatalf("migrated meta ID = %q, want %q", meta.ID, newLongID)
|
|
}
|
|
if meta.Name != "长会话" || meta.ParentID != "root-branch" {
|
|
t.Fatalf("migrated meta lost fields: %+v", meta)
|
|
}
|
|
childMeta, ok, err := LoadBranchMeta(childPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
|
|
}
|
|
if childMeta.ParentID != newLongID {
|
|
t.Fatalf("child ParentID = %q, want re-parented %q", childMeta.ParentID, newLongID)
|
|
}
|
|
|
|
before, err := filepath.Glob(filepath.Join(dir, "*"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars rerun: %v", err)
|
|
}
|
|
after, err := filepath.Glob(filepath.Join(dir, "*"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(before) != len(after) {
|
|
t.Fatalf("rerun changed the directory: before=%d entries, after=%d", len(before), len(after))
|
|
}
|
|
}
|
|
|
|
// TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails pins the
|
|
// point-of-no-return contract: once the transcript rename lands, the mapping
|
|
// must be committed — children re-parented, error surfaced as a warning —
|
|
// because the old name is gone and no later run can reconstruct it.
|
|
func TestReconcileOverlongRenameStillReparentsWhenSidecarMigrationFails(t *testing.T) {
|
|
dir := t.TempDir()
|
|
longID := strings.Repeat("m", 240)
|
|
oldPath := filepath.Join(dir, longID+".jsonl")
|
|
content := `{"role":"user","content":"hello"}` + "\n"
|
|
if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveBranchMeta(oldPath, BranchMeta{Name: "keep", ParentID: "root"}); err != nil {
|
|
t.Fatalf("SaveBranchMeta: %v", err)
|
|
}
|
|
childPath := filepath.Join(dir, "child.jsonl")
|
|
if err := os.WriteFile(childPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveBranchMeta(childPath, BranchMeta{Name: "child", ParentID: longID}); err != nil {
|
|
t.Fatalf("SaveBranchMeta child: %v", err)
|
|
}
|
|
|
|
newID := recoveryParentStem(longID)
|
|
newPath := filepath.Join(dir, newID+".jsonl")
|
|
// Sabotage the meta migration: its destination path is a directory.
|
|
if err := os.Mkdir(newPath+".meta", 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ReconcileSessionSidecars(dir); err == nil {
|
|
t.Fatal("expected the sabotaged meta migration to surface an error")
|
|
}
|
|
if _, err := os.Stat(newPath); err != nil {
|
|
t.Fatalf("renamed transcript missing after partial failure: %v", err)
|
|
}
|
|
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
|
t.Fatalf("old transcript still present (err=%v)", err)
|
|
}
|
|
childMeta, ok, err := LoadBranchMeta(childPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta child ok=%v err=%v", ok, err)
|
|
}
|
|
if childMeta.ParentID != newID {
|
|
t.Fatalf("child ParentID = %q, want %q despite sidecar failure", childMeta.ParentID, newID)
|
|
}
|
|
// The old meta stays behind as the durable copy of the un-migrated fields.
|
|
if _, err := os.Stat(oldPath + ".meta"); err != nil {
|
|
t.Fatalf("old meta lost though its migration failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestListSessionsOrdersByMTime makes sure the picker shows the most
|
|
// recently used conversation first — that's what users reach for when they
|
|
// hit `reasonix --continue`.
|
|
func TestListSessionsOrdersByMTime(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Write two sessions with explicit mtimes so the order is deterministic.
|
|
for _, name := range []string{"a.jsonl", "b.jsonl"} {
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + name})
|
|
if err := s.Save(filepath.Join(dir, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
oldT := time.Now().Add(-1 * time.Hour)
|
|
newT := time.Now()
|
|
if err := touch(filepath.Join(dir, "a.jsonl"), oldT); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := touch(filepath.Join(dir, "b.jsonl"), newT); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if !strings.HasSuffix(got[0].Path, "b.jsonl") {
|
|
t.Errorf("first entry = %s, want the newer 'b.jsonl'", got[0].Path)
|
|
}
|
|
if got[0].Turns != 1 || got[0].Preview != "preview for b.jsonl" {
|
|
t.Errorf("preview/turns wrong on newest: turns=%d preview=%q", got[0].Turns, got[0].Preview)
|
|
}
|
|
}
|
|
|
|
func TestListSessionsIncludesCustomTitle(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "named.jsonl")
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "first user prompt"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{
|
|
TopicTitle: "Topic title",
|
|
CustomTitle: "Custom session title",
|
|
Preview: "first user prompt",
|
|
Turns: 1,
|
|
SchemaVersion: BranchMetaCountsVersion,
|
|
}); err != nil {
|
|
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
|
|
}
|
|
|
|
got, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("len = %d, want 1", len(got))
|
|
}
|
|
if got[0].CustomTitle != "Custom session title" {
|
|
t.Fatalf("custom title = %q, want Custom session title", got[0].CustomTitle)
|
|
}
|
|
if got[0].TopicTitle != "Topic title" {
|
|
t.Fatalf("topic title = %q, want Topic title", got[0].TopicTitle)
|
|
}
|
|
}
|
|
|
|
func TestListSessionsSkipsCleanupPending(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "pending.jsonl")
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "preview"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MarkCleanupPending(path, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !IsCleanupPending(path) {
|
|
t.Fatal("session should be marked cleanup-pending")
|
|
}
|
|
|
|
got, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("cleanup-pending session should be hidden, got %+v", got)
|
|
}
|
|
|
|
if err := ClearCleanupPending(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err = ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Path != path {
|
|
t.Fatalf("session should be visible after clearing marker, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestListSessionsOrdersByLastActivityMeta(t *testing.T) {
|
|
dir := t.TempDir()
|
|
aPath := filepath.Join(dir, "a.jsonl")
|
|
bPath := filepath.Join(dir, "b.jsonl")
|
|
for _, path := range []string{aPath, bPath} {
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "preview for " + filepath.Base(path)})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
olderActivity := now.Add(-2 * time.Hour)
|
|
newerActivity := now.Add(-1 * time.Hour)
|
|
writeBranchMeta(t, aPath, now.Add(-24*time.Hour), newerActivity)
|
|
writeBranchMeta(t, bPath, now.Add(-24*time.Hour), olderActivity)
|
|
if err := touch(aPath, now.Add(-3*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := touch(bPath, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if got[0].Path != aPath {
|
|
t.Fatalf("first entry = %s, want activity-newer a.jsonl despite older file mtime", got[0].Path)
|
|
}
|
|
if !got[0].LastActivityAt.Equal(newerActivity) || !got[0].ModTime.Equal(newerActivity) {
|
|
t.Fatalf("activity fields = %s / %s, want %s", got[0].LastActivityAt, got[0].ModTime, newerActivity)
|
|
}
|
|
}
|
|
|
|
func TestListSessionOrderIncludesEmptySessionsWithoutPreviewScan(t *testing.T) {
|
|
dir := t.TempDir()
|
|
emptyPath := filepath.Join(dir, "empty.jsonl")
|
|
realPath := filepath.Join(dir, "real.jsonl")
|
|
if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "real prompt"})
|
|
if err := s.Save(realPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
writeBranchMeta(t, emptyPath, now, now.Add(time.Hour))
|
|
writeBranchMeta(t, realPath, now, now)
|
|
|
|
ordered, err := ListSessionOrder(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ordered) != 2 {
|
|
t.Fatalf("lightweight order len = %d, want 2", len(ordered))
|
|
}
|
|
if ordered[0].Path != emptyPath {
|
|
t.Fatalf("lightweight order first = %s, want newer empty session %s", ordered[0].Path, emptyPath)
|
|
}
|
|
|
|
listed, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(listed) != 1 || listed[0].Path != realPath {
|
|
t.Fatalf("ListSessions = %+v, want only the non-empty real session", listed)
|
|
}
|
|
}
|
|
|
|
func TestSessionListingsExposeRecoveryMetadata(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "recovered.jsonl")
|
|
s := NewSession("")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "continued recovery"})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
meta, ok, err := LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
|
|
}
|
|
meta.Recovered = true
|
|
meta.RecoveryDigest = strings.Repeat("a", 64)
|
|
meta.ParentID = "parent"
|
|
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ordered, err := ListSessionOrder(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ordered) != 1 {
|
|
t.Fatalf("ListSessionOrder len = %d, want 1", len(ordered))
|
|
}
|
|
if ordered[0].RecoveryDigest != meta.RecoveryDigest || ordered[0].ParentID != meta.ParentID {
|
|
t.Fatalf("ordered recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", ordered[0].RecoveryDigest, ordered[0].ParentID, meta.RecoveryDigest, meta.ParentID)
|
|
}
|
|
|
|
listed, err := ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(listed) != 1 {
|
|
t.Fatalf("ListSessions len = %d, want 1", len(listed))
|
|
}
|
|
if listed[0].RecoveryDigest != meta.RecoveryDigest || listed[0].ParentID != meta.ParentID {
|
|
t.Fatalf("listed recovery metadata = digest:%q parent:%q, want digest:%q parent:%q", listed[0].RecoveryDigest, listed[0].ParentID, meta.RecoveryDigest, meta.ParentID)
|
|
}
|
|
}
|
|
|
|
func writeBranchMeta(t *testing.T, path string, createdAt, updatedAt time.Time) {
|
|
t.Helper()
|
|
meta := BranchMeta{
|
|
ID: BranchID(path),
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
}
|
|
b, err := json.Marshal(meta)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(BranchMetaPath(path), append(b, '\n'), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestContinueSessionPathReusesPriorFile(t *testing.T) {
|
|
prev := filepath.Join("sessions", "20260602-120000.000000000-deepseek.jsonl")
|
|
if got := ContinueSessionPath(prev, "sessions", "other-model"); got != prev {
|
|
t.Fatalf("carried conversation should keep its file %q, got %q", prev, got)
|
|
}
|
|
}
|
|
|
|
func TestContinueSessionPathMintsFreshWhenNoPrior(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got := ContinueSessionPath("", dir, "deepseek")
|
|
if filepath.Dir(got) != dir || !strings.HasSuffix(got, ".jsonl") {
|
|
t.Fatalf("fresh path = %q, want a .jsonl under %q", got, dir)
|
|
}
|
|
}
|
|
|
|
func TestContinueSessionPathNoPersistence(t *testing.T) {
|
|
if got := ContinueSessionPath("", "", "deepseek"); got != "" {
|
|
t.Fatalf("no session dir should disable persistence, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestListSessionsMissingDir returns nil + no error so callers can fall
|
|
// through to a fresh session without special-casing.
|
|
func TestListSessionsMissingDir(t *testing.T) {
|
|
got, err := ListSessions(filepath.Join(t.TempDir(), "never-created"))
|
|
if err != nil || got != nil {
|
|
t.Errorf("missing dir = %v / %v, want nil/nil", got, err)
|
|
}
|
|
}
|
|
|
|
func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
|
|
t.Helper()
|
|
f, err := os.Open(SessionEventLogPath(path))
|
|
if err != nil {
|
|
t.Fatalf("open event log: %v", err)
|
|
}
|
|
defer f.Close()
|
|
dec := json.NewDecoder(f)
|
|
var out []sessionEventRecord
|
|
for {
|
|
var rec sessionEventRecord
|
|
if err := dec.Decode(&rec); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
t.Fatalf("decode event log: %v", err)
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
|
|
// overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
|
|
// sidecars instead of renaming each into a fake session with fabricated meta,
|
|
// and the owning transcript's rename must carry those sidecars along instead
|
|
// of orphaning them under the retired stem.
|
|
func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// 236-byte transcript name: past the 224 reconcile bound, while its
|
|
// .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
|
|
// exactly the window where the old suffix filter mistook them for
|
|
// overlong sessions.
|
|
id := strings.Repeat("s", 230)
|
|
oldPath := filepath.Join(dir, id+".jsonl")
|
|
content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
|
|
if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(store.SessionEventLog(oldPath),
|
|
[]byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(store.SessionConflictLog(oldPath),
|
|
[]byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ReconcileSessionSidecars(dir); err != nil {
|
|
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
|
}
|
|
|
|
newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
|
|
if _, err := os.Stat(newPath); err != nil {
|
|
t.Fatalf("renamed transcript missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
|
|
t.Fatalf("event log not migrated with the rename: %v", err)
|
|
}
|
|
if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
|
|
t.Fatalf("conflict log not migrated with the rename: %v", err)
|
|
}
|
|
for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionConflictLog(oldPath)} {
|
|
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
|
t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
|
|
t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
|
|
}
|
|
}
|
|
}
|