@@ 0,0 1,181 @@
+package llm
+
+import (
+ "context"
+ "testing"
+)
+
+// mockAPIClient is a minimal Backend for testing SessionManager.Compact.
+type mockAPIClient struct {
+ askResponse string
+ askTokens int
+ askError error
+}
+
+func (m *mockAPIClient) Model() string { return "claude-sonnet-4-20250514" }
+func (m *mockAPIClient) SetModel(string) {}
+func (m *mockAPIClient) Temperature() float64 { return 0.7 }
+func (m *mockAPIClient) SetTemperature(float64) error { return nil }
+func (m *mockAPIClient) SystemPrompt() string { return "" }
+func (m *mockAPIClient) SetSystemPrompt(string) {}
+func (m *mockAPIClient) ThinkingTokens() int { return 0 }
+func (m *mockAPIClient) SetThinkingTokens(int) {}
+func (m *mockAPIClient) Prefill() string { return "" }
+func (m *mockAPIClient) SetPrefill(string) {}
+func (m *mockAPIClient) LastTokens() int { return m.askTokens }
+func (m *mockAPIClient) TotalTokens() int { return 0 }
+func (m *mockAPIClient) ContextLimit() int { return 200000 }
+func (m *mockAPIClient) Compact(context.Context) error { return nil }
+func (m *mockAPIClient) Messages() []Message { return nil }
+func (m *mockAPIClient) MessagesJSON() ([]byte, error) { return []byte("[]"), nil }
+func (m *mockAPIClient) AddSystemMessage(string) {}
+func (m *mockAPIClient) Reset() {}
+func (m *mockAPIClient) Ask(_ context.Context, _ string) (string, error) {
+ return m.askResponse, m.askError
+}
+func (m *mockAPIClient) AskWithHistory(_ context.Context, _ []Message, _ string) (string, int, error) {
+ return m.askResponse, m.askTokens, m.askError
+}
+func (m *mockAPIClient) AskWithRequest(_ context.Context, _ AskRequest) (string, int, error) {
+ return m.askResponse, m.askTokens, m.askError
+}
+func (m *mockAPIClient) StartStream(context.Context, string) error { return nil }
+func (m *mockAPIClient) ReadStreamChunk() (string, bool) { return "", false }
+func (m *mockAPIClient) IsStreaming() bool { return false }
+func (m *mockAPIClient) WaitStream() {}
+
+var _ Backend = (*mockAPIClient)(nil)
+
+// ---- EstimatedContextTokens ----
+
+func TestSession_EstimatedContextTokens_Empty(t *testing.T) {
+ s := NewSession(0, DefaultSessionDefaults())
+ if got := s.EstimatedContextTokens(); got != 0 {
+ t.Errorf("empty session: got %d, want 0", got)
+ }
+}
+
+func TestSession_EstimatedContextTokens_Counts(t *testing.T) {
+ s := NewSession(0, DefaultSessionDefaults())
+ // 400 chars user + 200 chars assistant = 600 chars → 150 tokens
+ s.AddMessage("user", string(make([]byte, 400)))
+ s.AddMessage("assistant", string(make([]byte, 200)))
+ got := s.EstimatedContextTokens()
+ want := 150 // (400 + 200) / 4
+ if got != want {
+ t.Errorf("EstimatedContextTokens() = %d, want %d", got, want)
+ }
+}
+
+// ---- SessionManager.Compact ----
+
+func TestSessionManager_Compact_NotFound(t *testing.T) {
+ sm := NewSessionManager(&mockAPIClient{askResponse: "summary"})
+ err := sm.Compact(context.Background(), 999)
+ if err != ErrSessionNotFound {
+ t.Errorf("Compact(unknown) = %v, want ErrSessionNotFound", err)
+ }
+}
+
+func TestSessionManager_Compact_TooShort(t *testing.T) {
+ api := &mockAPIClient{askResponse: "summary", askTokens: 50}
+ sm := NewSessionManager(api)
+ id := sm.Create()
+
+ // 2 messages — below the "< 4" threshold
+ session := sm.Get(id)
+ session.AddMessage("user", "hello")
+ session.AddMessage("assistant", "hi")
+
+ if err := sm.Compact(context.Background(), id); err != nil {
+ t.Fatalf("Compact() error: %v", err)
+ }
+ // Messages should be unchanged (compaction skipped)
+ msgs := session.Messages()
+ if len(msgs) != 2 {
+ t.Errorf("short session: messages = %d, want 2 (no compaction)", len(msgs))
+ }
+}
+
+func TestSessionManager_Compact_ReplacesMessages(t *testing.T) {
+ api := &mockAPIClient{askResponse: "This is a summary.", askTokens: 300}
+ sm := NewSessionManager(api)
+ id := sm.Create()
+
+ session := sm.Get(id)
+ for i := 0; i < 3; i++ {
+ session.AddMessage("user", "question "+string(rune('A'+i)))
+ session.AddMessage("assistant", "answer "+string(rune('A'+i)))
+ }
+ session.AddTokens(10000)
+
+ if err := sm.Compact(context.Background(), id); err != nil {
+ t.Fatalf("Compact() error: %v", err)
+ }
+
+ msgs := session.Messages()
+ // Should be exactly 2 messages: context exchange
+ if len(msgs) != 2 {
+ t.Errorf("after compact: messages = %d, want 2", len(msgs))
+ }
+ if msgs[0].Role != "user" {
+ t.Errorf("msgs[0].Role = %q, want 'user'", msgs[0].Role)
+ }
+ if msgs[1].Role != "assistant" {
+ t.Errorf("msgs[1].Role = %q, want 'assistant'", msgs[1].Role)
+ }
+ // Summary should appear in first message
+ if got := msgs[0].Content; len(got) == 0 {
+ t.Error("msgs[0].Content is empty after compaction")
+ }
+}
+
+func TestSessionManager_Compact_ResetsTokens(t *testing.T) {
+ api := &mockAPIClient{askResponse: "summary", askTokens: 400}
+ sm := NewSessionManager(api)
+ id := sm.Create()
+
+ session := sm.Get(id)
+ for i := 0; i < 3; i++ {
+ session.AddMessage("user", "msg")
+ session.AddMessage("assistant", "reply")
+ }
+ session.AddTokens(50000)
+
+ if err := sm.Compact(context.Background(), id); err != nil {
+ t.Fatalf("Compact() error: %v", err)
+ }
+
+ // totalTokens should be reset to what the compaction LLM call returned
+ if got := session.TotalTokens(); got != 400 {
+ t.Errorf("TotalTokens after compact = %d, want 400", got)
+ }
+}
+
+func TestSessionManager_ContextLimit(t *testing.T) {
+ sm := NewSessionManager(&mockAPIClient{})
+ if got := sm.ContextLimit(); got != 200000 {
+ t.Errorf("ContextLimit() = %d, want 200000", got)
+ }
+}
+
+func TestSessionManager_EstimatedContextTokens(t *testing.T) {
+ sm := NewSessionManager(&mockAPIClient{})
+ id := sm.Create()
+ session := sm.Get(id)
+
+ // 800 chars / 4 = 200 tokens
+ session.AddMessage("user", string(make([]byte, 800)))
+
+ got := sm.EstimatedContextTokens(id)
+ if got != 200 {
+ t.Errorf("EstimatedContextTokens() = %d, want 200", got)
+ }
+}
+
+func TestSessionManager_EstimatedContextTokens_NotFound(t *testing.T) {
+ sm := NewSessionManager(&mockAPIClient{})
+ if got := sm.EstimatedContextTokens(999); got != 0 {
+ t.Errorf("EstimatedContextTokens(unknown) = %d, want 0", got)
+ }
+}
@@ 0,0 1,228 @@
+package llmfs
+
+import (
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+)
+
+// newTestSession creates a SessionManager with a mock backend and a fresh session.
+// Returns the manager and the session ID.
+func newTestSession() (*llm.SessionManager, int) {
+ mock := NewMockBackend()
+ mock.askResponse = "This is a compact summary of the conversation."
+ sm := llm.NewSessionManager(mock)
+ id := sm.Create()
+ return sm, id
+}
+
+// ---- SessionCompactFile ----
+
+func TestSessionCompactFile_Read(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionCompactFile(sm, id)
+
+ buf := make([]byte, 100)
+ n, err := f.Read(buf, 0)
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+ content := string(buf[:n])
+ if content == "" {
+ t.Error("Read() returned empty content")
+ }
+}
+
+func TestSessionCompactFile_Read_EOF(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionCompactFile(sm, id)
+
+ buf := make([]byte, 100)
+ n, err := f.Read(buf, 10000)
+ if err != io.EOF {
+ t.Errorf("Read(large offset) = %v, want io.EOF", err)
+ }
+ if n != 0 {
+ t.Errorf("Read(large offset) n = %d, want 0", n)
+ }
+}
+
+func TestSessionCompactFile_Write_NoOp_ShortHistory(t *testing.T) {
+ sm, id := newTestSession()
+ session := sm.Get(id)
+ session.AddMessage("user", "hello")
+ session.AddMessage("assistant", "hi")
+
+ f := NewSessionCompactFile(sm, id)
+ n, err := f.Write([]byte("compact"), 0)
+ if err != nil {
+ t.Fatalf("Write() error: %v", err)
+ }
+ if n != 7 {
+ t.Errorf("Write() n = %d, want 7", n)
+ }
+ // Session with < 4 messages: compaction is a no-op (messages unchanged)
+ msgs := session.Messages()
+ if len(msgs) != 2 {
+ t.Errorf("after no-op compact: messages = %d, want 2", len(msgs))
+ }
+}
+
+func TestSessionCompactFile_Write_Compacts(t *testing.T) {
+ sm, id := newTestSession()
+ session := sm.Get(id)
+ // Add 3 turns (6 messages) to pass the threshold
+ for i := 0; i < 3; i++ {
+ session.AddMessage("user", "question")
+ session.AddMessage("assistant", "answer")
+ }
+
+ f := NewSessionCompactFile(sm, id)
+ _, err := f.Write([]byte("compact"), 0)
+ if err != nil {
+ t.Fatalf("Write() error: %v", err)
+ }
+
+ // After compaction messages should be condensed
+ msgs := session.Messages()
+ if len(msgs) >= 6 {
+ t.Errorf("after compact: messages = %d, expected fewer than 6", len(msgs))
+ }
+}
+
+func TestSessionCompactFile_Stat(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionCompactFile(sm, id)
+ stat := f.Stat()
+ if stat.Length == 0 {
+ t.Error("Stat().Length should be non-zero")
+ }
+}
+
+// ---- SessionUsageFile ----
+
+func TestSessionUsageFile_Read_Format(t *testing.T) {
+ sm, id := newTestSession()
+ session := sm.Get(id)
+ // 400 chars → 100 estimated tokens
+ session.AddMessage("user", strings.Repeat("x", 400))
+
+ f := NewSessionUsageFile(sm, id)
+ buf := make([]byte, 64)
+ n, err := f.Read(buf, 0)
+ if err != nil {
+ t.Fatalf("Read() error: %v", err)
+ }
+
+ content := string(buf[:n])
+ // Should be "100/200000\n"
+ if content != "100/200000\n" {
+ t.Errorf("Read() = %q, want %q", content, "100/200000\n")
+ }
+}
+
+func TestSessionUsageFile_Read_EOF(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionUsageFile(sm, id)
+
+ buf := make([]byte, 64)
+ n, err := f.Read(buf, 10000)
+ if err != io.EOF {
+ t.Errorf("Read(large offset) = %v, want io.EOF", err)
+ }
+ if n != 0 {
+ t.Errorf("Read(large offset) n = %d, want 0", n)
+ }
+}
+
+func TestSessionUsageFile_Write_ReadOnly(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionUsageFile(sm, id)
+
+ n, err := f.Write([]byte("anything"), 0)
+ if err == nil {
+ t.Error("Write() should return error (read-only)")
+ }
+ if n != 0 {
+ t.Errorf("Write() n = %d, want 0", n)
+ }
+}
+
+func TestSessionUsageFile_Stat_Length(t *testing.T) {
+ sm, id := newTestSession()
+ f := NewSessionUsageFile(sm, id)
+ stat := f.Stat()
+ // "0/200000\n" = 9 chars
+ if stat.Length != 9 {
+ t.Errorf("Stat().Length = %d, want 9 (for '0/200000\\n')", stat.Length)
+ }
+}
+
+func TestSessionUsageFile_Reflects_Content(t *testing.T) {
+ sm, id := newTestSession()
+ session := sm.Get(id)
+ f := NewSessionUsageFile(sm, id)
+
+ buf := make([]byte, 64)
+
+ // Initially empty
+ n, _ := f.Read(buf, 0)
+ if string(buf[:n]) != "0/200000\n" {
+ t.Errorf("initial: got %q, want '0/200000\\n'", string(buf[:n]))
+ }
+
+ // Add 800 chars → 200 estimated tokens
+ session.AddMessage("user", strings.Repeat("a", 800))
+
+ n, _ = f.Read(buf, 0)
+ if string(buf[:n]) != "200/200000\n" {
+ t.Errorf("after add: got %q, want '200/200000\\n'", string(buf[:n]))
+ }
+}
+
+// ---- SessionDir includes compact and usage ----
+
+func TestSessionDir_HasCompactAndUsage(t *testing.T) {
+ sm, id := newTestSession()
+ dir := NewSessionDir(sm, id)
+
+ children := dir.Children()
+ names := make(map[string]bool)
+ for _, f := range children {
+ names[f.Stat().Name] = true
+ }
+
+ for _, want := range []string{"compact", "usage"} {
+ if !names[want] {
+ t.Errorf("SessionDir.Children() missing %q", want)
+ }
+ }
+}
+
+func TestSessionDir_LookupCompact(t *testing.T) {
+ sm, id := newTestSession()
+ dir := NewSessionDir(sm, id)
+
+ f, err := dir.Lookup("compact")
+ if err != nil {
+ t.Fatalf("Lookup('compact') error: %v", err)
+ }
+ if _, ok := f.(*SessionCompactFile); !ok {
+ t.Errorf("Lookup('compact') returned %T, want *SessionCompactFile", f)
+ }
+}
+
+func TestSessionDir_LookupUsage(t *testing.T) {
+ sm, id := newTestSession()
+ dir := NewSessionDir(sm, id)
+
+ f, err := dir.Lookup("usage")
+ if err != nil {
+ t.Fatalf("Lookup('usage') error: %v", err)
+ }
+ if _, ok := f.(*SessionUsageFile); !ok {
+ t.Errorf("Lookup('usage') returned %T, want *SessionUsageFile", f)
+ }
+}