~kris/9p

llm9p

58bb4b6e6d26e0879fafa3bfdfa863bfef154b51 — pdfinn 6 months ago 6da61fd
test(llm9p): add tests for per-session compact and usage files

- session_compact_test.go (llm): Tests for Session.EstimatedContextTokens,
  SessionManager.Compact (not found, too short, replaces messages, resets
  tokens), SessionManager.ContextLimit and EstimatedContextTokens.

- session_compact.go (llmfs): Fix Stat().Length for SessionCompactFile
  (was 0 from BaseFile default; now returns fixed read-msg length).

- session_compact_test.go (llmfs): Tests for SessionCompactFile (read,
  read EOF, write no-op short history, write compacts, stat), for
  SessionUsageFile (format, EOF, read-only write, stat, dynamic content),
  and SessionDir.Children/Lookup wiring for compact and usage.

52 tests total, all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A internal/llm/session_compact_test.go => internal/llm/session_compact_test.go +181 -0
@@ 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)
	}
}

M internal/llmfs/session_compact.go => internal/llmfs/session_compact.go +3 -1
@@ 44,5 44,7 @@ func (f *SessionCompactFile) Write(p []byte, offset int64) (int, error) {

// Stat returns the file's metadata.
func (f *SessionCompactFile) Stat() protocol.Stat {
	return f.BaseFile.Stat()
	s := f.BaseFile.Stat()
	s.Length = uint64(len("write to compact conversation\n"))
	return s
}

A internal/llmfs/session_compact_test.go => internal/llmfs/session_compact_test.go +228 -0
@@ 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)
	}
}