Why: long multi-turn executions grow the JSONL transcript until they hit provider context limits (parity-matrix #11), and a crashed append left a partial line that made the whole session unloadable (parity-matrix #12 resume semantics). Evidence: go vet ./... clean; go test ./... 13 packages ok. Session cases: crash-residue tail skipped while mid-file corruption still errors, Stats line/byte counts, meta sidecar roundtrip (missing meta nil), Compact rewriting system+summary+keepLastN atomically with no tmp residue, no-op on short transcripts, summarizer error leaving the file untouched. Adapter case: threshold-triggered compaction runs before the loop and the summary marker reaches both the transcript and the model's context. Impact: session gains Stats/SaveMeta/ReadMeta/Compact; native.Options gains Compact *CompactConfig (default summarizer is one tool-less stream call; compaction failure logs and continues - optimization, never a silent failure); session meta sidecar records model/scope/ exec id per execution. Refs: docs/parity-matrix.md #11/#12 (Batch3 scope).
160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
package session
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-native/runtime/llm"
|
|
)
|
|
|
|
func appendMsgs(t *testing.T, s *Store, msgs ...llm.Message) {
|
|
t.Helper()
|
|
for _, m := range msgs {
|
|
if err := s.Append(m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadSkipsCrashResidueTail(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, err := Open(filepath.Join(dir, "t.jsonl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appendMsgs(t, s,
|
|
llm.Message{Role: llm.RoleSystem, Content: "sys"},
|
|
llm.Message{Role: llm.RoleUser, Content: "hi"},
|
|
)
|
|
// 模拟崩溃半行:无换行结尾的残缺 JSON。
|
|
f, _ := os.OpenFile(s.Path(), os.O_APPEND|os.O_WRONLY, 0o644)
|
|
_, _ = f.WriteString(`{"role":"assistant","content":"trunca`)
|
|
_ = f.Close()
|
|
|
|
msgs, err := s.Load()
|
|
if err != nil {
|
|
t.Fatalf("crash residue must not poison the session: %v", err)
|
|
}
|
|
if len(msgs) != 2 || msgs[0].Role != llm.RoleSystem || msgs[1].Content != "hi" {
|
|
t.Fatalf("want sys+hi, got %+v", msgs)
|
|
}
|
|
}
|
|
|
|
func TestLoadStillFailsOnMidFileCorruption(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "t.jsonl")
|
|
if err := os.WriteFile(path, []byte("{\"bad json}\n{\"role\":\"user\",\"content\":\"ok\"}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &Store{path: path}
|
|
if _, err := s.Load(); err == nil {
|
|
t.Fatal("mid-file corruption must still error")
|
|
}
|
|
}
|
|
|
|
func TestStatsAndMeta(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, _ := Open(filepath.Join(dir, "t.jsonl"))
|
|
appendMsgs(t, s,
|
|
llm.Message{Role: llm.RoleSystem, Content: "sys"},
|
|
llm.Message{Role: llm.RoleUser, Content: "q"},
|
|
)
|
|
st := s.Stats()
|
|
if st.Lines != 2 || st.Bytes <= 0 {
|
|
t.Fatalf("stats wrong: %+v", st)
|
|
}
|
|
if err := s.SaveMeta(map[string]string{"model": "m1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := s.ReadMeta()
|
|
if meta == nil || meta["model"] != "m1" {
|
|
t.Fatalf("meta roundtrip failed: %v", meta)
|
|
}
|
|
if other := (&Store{path: filepath.Join(dir, "missing.jsonl")}).ReadMeta(); other != nil {
|
|
t.Fatalf("missing meta must return nil, got %v", other)
|
|
}
|
|
}
|
|
|
|
func TestCompactRewritesTranscript(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, _ := Open(filepath.Join(dir, "t.jsonl"))
|
|
appendMsgs(t, s, llm.Message{Role: llm.RoleSystem, Content: "sys"})
|
|
for i := 0; i < 10; i++ {
|
|
appendMsgs(t, s,
|
|
llm.Message{Role: llm.RoleUser, Content: "early" + string(rune('a'+i))},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "reply"},
|
|
)
|
|
}
|
|
var seenHead []llm.Message
|
|
done, err := s.Compact(3, func(messages []llm.Message) (string, error) {
|
|
seenHead = messages
|
|
return "这是摘要", nil
|
|
})
|
|
if err != nil || !done {
|
|
t.Fatalf("compact failed: %v done=%v", err, done)
|
|
}
|
|
if len(seenHead) != 17 { // 20 - system - 3 tail
|
|
t.Fatalf("summarizer should receive 17 head messages, got %d", len(seenHead))
|
|
}
|
|
msgs, err := s.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msgs) != 5 { // system + summary + 3 tail
|
|
t.Fatalf("want 5 messages after compact, got %d", len(msgs))
|
|
}
|
|
if msgs[0].Role != llm.RoleSystem {
|
|
t.Fatal("system message must be preserved")
|
|
}
|
|
if !strings.Contains(msgs[1].Content, "[conversation summary]") || !strings.Contains(msgs[1].Content, "这是摘要") {
|
|
t.Fatalf("summary message wrong: %+v", msgs[1])
|
|
}
|
|
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
|
|
t.Fatalf("tail order wrong: %+v", msgs[2:])
|
|
}
|
|
// 无 tmp 残留。
|
|
if _, err := os.Stat(s.Path() + ".compact.tmp"); !os.IsNotExist(err) {
|
|
t.Fatal("compact tmp file must not remain")
|
|
}
|
|
}
|
|
|
|
func TestCompactNoopWhenShort(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, _ := Open(filepath.Join(dir, "t.jsonl"))
|
|
appendMsgs(t, s,
|
|
llm.Message{Role: llm.RoleSystem, Content: "sys"},
|
|
llm.Message{Role: llm.RoleUser, Content: "q"},
|
|
)
|
|
done, err := s.Compact(8, func([]llm.Message) (string, error) { return "x", nil })
|
|
if err != nil || done {
|
|
t.Fatalf("short transcript must be a no-op: %v %v", done, err)
|
|
}
|
|
msgs, _ := s.Load()
|
|
if len(msgs) != 2 {
|
|
t.Fatal("no-op compact must not touch the file")
|
|
}
|
|
}
|
|
|
|
func TestCompactSummarizerErrorAborts(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, _ := Open(filepath.Join(dir, "t.jsonl"))
|
|
appendMsgs(t, s, llm.Message{Role: llm.RoleSystem, Content: "sys"})
|
|
for i := 0; i < 6; i++ {
|
|
appendMsgs(t, s, llm.Message{Role: llm.RoleUser, Content: "m"})
|
|
}
|
|
done, err := s.Compact(2, func([]llm.Message) (string, error) { return "", errStringT("boom") })
|
|
if err == nil || done {
|
|
t.Fatal("summarizer error must abort compaction")
|
|
}
|
|
msgs, _ := s.Load()
|
|
if len(msgs) != 7 {
|
|
t.Fatal("failed compaction must leave the transcript untouched")
|
|
}
|
|
}
|
|
|
|
type errStringT string
|
|
|
|
func (e errStringT) Error() string { return string(e) }
|