From 58bb4b6e6d26e0879fafa3bfdfa863bfef154b51 Mon Sep 17 00:00:00 2001 From: pdfinn Date: Sat, 21 Feb 2026 18:32:19 +0800 Subject: [PATCH] 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 --- internal/llm/session_compact_test.go | 181 ++++++++++++++++++++ internal/llmfs/session_compact.go | 4 +- internal/llmfs/session_compact_test.go | 228 +++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 internal/llm/session_compact_test.go create mode 100644 internal/llmfs/session_compact_test.go diff --git a/internal/llm/session_compact_test.go b/internal/llm/session_compact_test.go new file mode 100644 index 0000000000000000000000000000000000000000..073cb0df08a2719dc73efdf39d4d2d19f85b8ca9 --- /dev/null +++ b/internal/llm/session_compact_test.go @@ -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) + } +} diff --git a/internal/llmfs/session_compact.go b/internal/llmfs/session_compact.go index 45dcf2e05d67b974c71789cbd8814d65cec68106..3179521f38751b2687eeb84bea37d708bcdafd81 100644 --- a/internal/llmfs/session_compact.go +++ b/internal/llmfs/session_compact.go @@ -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 } diff --git a/internal/llmfs/session_compact_test.go b/internal/llmfs/session_compact_test.go new file mode 100644 index 0000000000000000000000000000000000000000..31635bb11eaef11917a8ee95bf2c1fda4d8fcc2b --- /dev/null +++ b/internal/llmfs/session_compact_test.go @@ -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) + } +}