v0.0.4: add sessions and refine tui
This commit is contained in:
+165
-8
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentu/internal/agent"
|
||||
"agentu/internal/config"
|
||||
@@ -12,14 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
agent *agent.Agent
|
||||
httpClient *http.Client
|
||||
providerName string
|
||||
provider config.ProviderConfig
|
||||
cfg *config.Config
|
||||
agent *agent.Agent
|
||||
httpClient *http.Client
|
||||
providerName string
|
||||
provider config.ProviderConfig
|
||||
store *Store
|
||||
currentSession *Session
|
||||
}
|
||||
|
||||
func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client) (*Manager, error) {
|
||||
func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Client, store *Store) (*Manager, error) {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
@@ -28,13 +31,160 @@ func NewManager(cfg *config.Config, assistant *agent.Agent, httpClient *http.Cli
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no provider is configured")
|
||||
}
|
||||
return &Manager{
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
agent: assistant,
|
||||
httpClient: httpClient,
|
||||
providerName: providerName,
|
||||
provider: provider,
|
||||
}, nil
|
||||
store: store,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResumeLatest loads the most recent session, or creates a new one.
|
||||
func (m *Manager) ResumeLatest() error {
|
||||
if m.store == nil {
|
||||
return nil
|
||||
}
|
||||
metas, err := m.store.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(metas) > 0 {
|
||||
return m.Resume(metas[0].ID)
|
||||
}
|
||||
m.newSession()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume loads a session by ID and restores it into the agent.
|
||||
func (m *Manager) Resume(id string) error {
|
||||
if m.store == nil {
|
||||
return fmt.Errorf("session storage is not configured")
|
||||
}
|
||||
sess, err := m.store.Load(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.currentSession = sess
|
||||
m.agent.SetMessages(sess.Messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists the current session to disk.
|
||||
func (m *Manager) Save() error {
|
||||
if m.store == nil || m.currentSession == nil {
|
||||
return nil
|
||||
}
|
||||
m.currentSession.Messages = m.agent.Messages()
|
||||
m.currentSession.Provider = m.providerName
|
||||
m.currentSession.Model = m.provider.Model
|
||||
// Auto-name if empty
|
||||
if m.currentSession.Name == "" {
|
||||
m.currentSession.Name = DefaultName(m.currentSession.Messages)
|
||||
}
|
||||
return m.store.Save(m.currentSession)
|
||||
}
|
||||
|
||||
// Rename changes the current session name and saves.
|
||||
func (m *Manager) Rename(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("name is required")
|
||||
}
|
||||
if m.currentSession == nil {
|
||||
return "", fmt.Errorf("no active session")
|
||||
}
|
||||
m.currentSession.Name = name
|
||||
if err := m.Save(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Renamed session to %q.", name), nil
|
||||
}
|
||||
|
||||
// Sessions returns a formatted list of all sessions.
|
||||
func (m *Manager) Sessions() (string, error) {
|
||||
if m.store == nil {
|
||||
return "", fmt.Errorf("session storage is not configured")
|
||||
}
|
||||
metas, err := m.store.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
return "No saved sessions.", nil
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Sessions:\n")
|
||||
for _, meta := range metas {
|
||||
name := meta.Name
|
||||
if name == "" {
|
||||
name = "(unnamed)"
|
||||
}
|
||||
current := ""
|
||||
if m.currentSession != nil && meta.ID == m.currentSession.ID {
|
||||
current = " (current)"
|
||||
}
|
||||
ts := meta.UpdatedAt.Local().Format("2006-01-02 15:04")
|
||||
fmt.Fprintf(&b, " %s %-30s %s %d msgs%s\n", meta.ID, fitName(name, 30), ts, meta.MessageCount, current)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// NewSession starts a fresh session, saving the current one first.
|
||||
func (m *Manager) NewSession() (string, error) {
|
||||
// Save current if it has user messages
|
||||
if m.currentSession != nil {
|
||||
msgs := m.agent.Messages()
|
||||
hasUser := false
|
||||
for _, msg := range msgs {
|
||||
if msg.Role == llm.RoleUser {
|
||||
hasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasUser {
|
||||
m.currentSession.Messages = msgs
|
||||
m.currentSession.Provider = m.providerName
|
||||
m.currentSession.Model = m.provider.Model
|
||||
if m.currentSession.Name == "" {
|
||||
m.currentSession.Name = DefaultName(msgs)
|
||||
}
|
||||
if m.store != nil {
|
||||
_ = m.store.Save(m.currentSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.newSession()
|
||||
return "Session saved. Starting new session.", nil
|
||||
}
|
||||
|
||||
func (m *Manager) newSession() {
|
||||
m.currentSession = &Session{
|
||||
ID: GenerateID(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Provider: m.providerName,
|
||||
Model: m.provider.Model,
|
||||
}
|
||||
m.agent.Clear()
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentSessionID() string {
|
||||
if m.currentSession == nil {
|
||||
return ""
|
||||
}
|
||||
return m.currentSession.ID
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentSessionName() string {
|
||||
if m.currentSession == nil {
|
||||
return ""
|
||||
}
|
||||
if m.currentSession.Name != "" {
|
||||
return m.currentSession.Name
|
||||
}
|
||||
return m.currentSession.ID
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentProvider() string {
|
||||
@@ -140,3 +290,10 @@ func contains(values []string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fitName(name string, width int) string {
|
||||
if len(name) <= width {
|
||||
return name
|
||||
}
|
||||
return name[:width-3] + "..."
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,8 +20,8 @@ func (p *recordingProvider) ChatStream(ctx context.Context, req llm.ChatRequest,
|
||||
return emit(llm.StreamEvent{Content: "ok"})
|
||||
}
|
||||
|
||||
func TestManagerSwitchesModelAndThinking(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Providers: map[string]config.ProviderConfig{
|
||||
"one": {
|
||||
Name: "one",
|
||||
@@ -34,6 +35,11 @@ func TestManagerSwitchesModelAndThinking(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testManager(t *testing.T) (*Manager, *agent.Agent, *recordingProvider) {
|
||||
t.Helper()
|
||||
cfg := testConfig()
|
||||
provider := &recordingProvider{}
|
||||
assistant := agent.New(agent.Options{
|
||||
Provider: provider,
|
||||
@@ -43,10 +49,19 @@ func TestManagerSwitchesModelAndThinking(t *testing.T) {
|
||||
ThinkingParam: "thinking",
|
||||
ThinkingEnabled: true,
|
||||
})
|
||||
manager, err := NewManager(cfg, assistant, nil)
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := NewManager(cfg, assistant, nil, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manager, assistant, provider
|
||||
}
|
||||
|
||||
func TestManagerSwitchesModelAndThinking(t *testing.T) {
|
||||
manager, assistant, provider := testManager(t)
|
||||
|
||||
if _, err := manager.SetModel("model-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,7 +95,8 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
|
||||
},
|
||||
}
|
||||
assistant := agent.New(agent.Options{Provider: &recordingProvider{}, Model: "model-a"})
|
||||
manager, err := NewManager(cfg, assistant, nil)
|
||||
store, _ := NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
manager, err := NewManager(cfg, assistant, nil, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -92,3 +108,159 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
|
||||
t.Fatal("expected invalid thinking error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSaveAndResume(t *testing.T) {
|
||||
manager, assistant, _ := testManager(t)
|
||||
|
||||
// Start new session and have a conversation
|
||||
manager.newSession()
|
||||
var out strings.Builder
|
||||
assistant.RunTurn(context.Background(), "hello world", &out, nil)
|
||||
|
||||
// Save
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
savedID := manager.CurrentSessionID()
|
||||
if savedID == "" {
|
||||
t.Fatal("expected session ID after save")
|
||||
}
|
||||
// Name should be auto-derived from first user message
|
||||
if manager.CurrentSessionName() != "hello world" {
|
||||
t.Fatalf("session name = %q, want %q", manager.CurrentSessionName(), "hello world")
|
||||
}
|
||||
|
||||
// Start a new session
|
||||
manager.newSession()
|
||||
if manager.CurrentSessionID() == savedID {
|
||||
t.Fatal("new session should have different ID")
|
||||
}
|
||||
|
||||
// Resume
|
||||
if err := manager.Resume(savedID); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() != savedID {
|
||||
t.Fatalf("after resume, ID = %q, want %q", manager.CurrentSessionID(), savedID)
|
||||
}
|
||||
msgs := assistant.Messages()
|
||||
hasUser := false
|
||||
for _, m := range msgs {
|
||||
if m.Role == llm.RoleUser && m.Content == "hello world" {
|
||||
hasUser = true
|
||||
}
|
||||
}
|
||||
if !hasUser {
|
||||
t.Fatal("resumed session should contain original user message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRename(t *testing.T) {
|
||||
manager, _, _ := testManager(t)
|
||||
manager.newSession()
|
||||
|
||||
msg, err := manager.Rename("my cool session")
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "my cool session") {
|
||||
t.Fatalf("rename message = %q", msg)
|
||||
}
|
||||
if manager.CurrentSessionName() != "my cool session" {
|
||||
t.Fatalf("name = %q", manager.CurrentSessionName())
|
||||
}
|
||||
|
||||
// Verify persisted
|
||||
_, err = manager.store.Load(manager.CurrentSessionID())
|
||||
if err != nil {
|
||||
t.Fatalf("load after rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRenameEmpty(t *testing.T) {
|
||||
manager, _, _ := testManager(t)
|
||||
manager.newSession()
|
||||
_, err := manager.Rename(" ")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSessions(t *testing.T) {
|
||||
manager, _, _ := testManager(t)
|
||||
|
||||
// No sessions yet
|
||||
out, err := manager.Sessions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "No saved sessions") {
|
||||
t.Fatalf("expected 'no saved sessions', got: %s", out)
|
||||
}
|
||||
|
||||
// Create and save a session
|
||||
manager.newSession()
|
||||
manager.Save()
|
||||
|
||||
out, err = manager.Sessions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "No saved sessions") {
|
||||
t.Fatal("expected session list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerNewSession(t *testing.T) {
|
||||
manager, assistant, _ := testManager(t)
|
||||
manager.newSession()
|
||||
var out strings.Builder
|
||||
assistant.RunTurn(context.Background(), "first message", &out, nil)
|
||||
|
||||
msg, err := manager.NewSession()
|
||||
if err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "Starting new session") {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
// Old session should be saved
|
||||
metas, _ := manager.store.List()
|
||||
if len(metas) != 1 {
|
||||
t.Fatalf("expected 1 saved session, got %d", len(metas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerResumeLatest(t *testing.T) {
|
||||
manager, assistant, _ := testManager(t)
|
||||
|
||||
// Create two sessions
|
||||
manager.newSession()
|
||||
var out strings.Builder
|
||||
assistant.RunTurn(context.Background(), "session one", &out, nil)
|
||||
manager.Save()
|
||||
id1 := manager.CurrentSessionID()
|
||||
|
||||
manager.NewSession()
|
||||
assistant.RunTurn(context.Background(), "session two", &out, nil)
|
||||
manager.Save()
|
||||
id2 := manager.CurrentSessionID()
|
||||
|
||||
// Clear and resume latest
|
||||
manager.newSession()
|
||||
if err := manager.ResumeLatest(); err != nil {
|
||||
t.Fatalf("resume latest: %v", err)
|
||||
}
|
||||
// Should resume the most recently updated (id2)
|
||||
if manager.CurrentSessionID() != id2 {
|
||||
t.Fatalf("expected to resume %s, got %s", id2, manager.CurrentSessionID())
|
||||
}
|
||||
|
||||
// Also verify id1 is resumable
|
||||
if err := manager.Resume(id1); err != nil {
|
||||
t.Fatalf("resume id1: %v", err)
|
||||
}
|
||||
if manager.CurrentSessionID() != id1 {
|
||||
t.Fatalf("expected %s, got %s", id1, manager.CurrentSessionID())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
// Session represents a persisted conversation.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Messages []llm.Message `json:"messages"`
|
||||
}
|
||||
|
||||
// SessionMeta is a lightweight summary for listing.
|
||||
type SessionMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
MessageCount int `json:"message_count"`
|
||||
}
|
||||
|
||||
// Store manages session files under a directory.
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewStore creates a store, ensuring the directory exists.
|
||||
func NewStore(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create session dir: %w", err)
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Save writes a session to disk.
|
||||
func (s *Store) Save(sess *Session) error {
|
||||
if sess.ID == "" {
|
||||
return fmt.Errorf("session ID is required")
|
||||
}
|
||||
sess.UpdatedAt = time.Now().UTC()
|
||||
if sess.CreatedAt.IsZero() {
|
||||
sess.CreatedAt = sess.UpdatedAt
|
||||
}
|
||||
data, err := json.MarshalIndent(sess, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
path := s.filePath(sess.ID)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads a session by ID.
|
||||
func (s *Store) Load(id string) (*Session, error) {
|
||||
path := s.filePath(id)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("session %q not found", id)
|
||||
}
|
||||
return nil, fmt.Errorf("read session: %w", err)
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return nil, fmt.Errorf("parse session: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// List returns metadata for all sessions, sorted by updated_at descending.
|
||||
func (s *Store) List() ([]SessionMeta, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read session dir: %w", err)
|
||||
}
|
||||
var metas []SessionMeta
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
continue
|
||||
}
|
||||
metas = append(metas, SessionMeta{
|
||||
ID: sess.ID,
|
||||
Name: sess.Name,
|
||||
CreatedAt: sess.CreatedAt,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
Provider: sess.Provider,
|
||||
Model: sess.Model,
|
||||
MessageCount: len(sess.Messages),
|
||||
})
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||
})
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
// Delete removes a session file.
|
||||
func (s *Store) Delete(id string) error {
|
||||
path := s.filePath(id)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("session %q not found", id)
|
||||
}
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) filePath(id string) string {
|
||||
return filepath.Join(s.dir, id+".json")
|
||||
}
|
||||
|
||||
// GenerateID creates a random 8-character hex session ID.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// DefaultName derives a session name from the first user message.
|
||||
func DefaultName(messages []llm.Message) string {
|
||||
for _, msg := range messages {
|
||||
if msg.Role != llm.RoleUser {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
// Take first line, truncate at 40 chars at word boundary
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[:idx]
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) > 40 {
|
||||
text = text[:40]
|
||||
if idx := strings.LastIndexByte(text, ' '); idx > 20 {
|
||||
text = text[:idx]
|
||||
}
|
||||
text += "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
func tempStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
sess := &Session{
|
||||
ID: GenerateID(),
|
||||
Name: "test session",
|
||||
Provider: "loveuer",
|
||||
Model: "deepseek-v4-flash",
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: "you are helpful"},
|
||||
{Role: llm.RoleUser, Content: "hello"},
|
||||
{Role: llm.RoleAssistant, Content: "hi there"},
|
||||
},
|
||||
}
|
||||
if err := store.Save(sess); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if loaded.ID != sess.ID {
|
||||
t.Errorf("ID = %q, want %q", loaded.ID, sess.ID)
|
||||
}
|
||||
if loaded.Name != sess.Name {
|
||||
t.Errorf("Name = %q, want %q", loaded.Name, sess.Name)
|
||||
}
|
||||
if len(loaded.Messages) != 3 {
|
||||
t.Errorf("Messages len = %d, want 3", len(loaded.Messages))
|
||||
}
|
||||
if loaded.Messages[1].Content != "hello" {
|
||||
t.Errorf("Messages[1].Content = %q, want %q", loaded.Messages[1].Content, "hello")
|
||||
}
|
||||
if loaded.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNotFound(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
_, err := store.Load("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
// Empty list
|
||||
metas, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(metas))
|
||||
}
|
||||
|
||||
// Save two sessions
|
||||
store.Save(&Session{ID: "aaa", Name: "first", Messages: []llm.Message{{Role: "user", Content: "a"}}})
|
||||
store.Save(&Session{ID: "bbb", Name: "second", Messages: []llm.Message{{Role: "user", Content: "b"}, {Role: "assistant", Content: "c"}}})
|
||||
|
||||
metas, err = store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("expected 2 sessions, got %d", len(metas))
|
||||
}
|
||||
// Sorted by updated_at desc
|
||||
if metas[0].ID != "bbb" && metas[0].ID != "aaa" {
|
||||
t.Errorf("unexpected first session ID: %q", metas[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
store.Save(&Session{ID: "todelete", Name: "temp"})
|
||||
|
||||
if err := store.Delete("todelete"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
_, err := store.Load("todelete")
|
||||
if err == nil {
|
||||
t.Fatal("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNotFound(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
err := store.Delete("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRequiresID(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
err := store.Save(&Session{Name: "no id"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateID(t *testing.T) {
|
||||
id := GenerateID()
|
||||
if len(id) != 8 {
|
||||
t.Errorf("ID length = %d, want 8", len(id))
|
||||
}
|
||||
id2 := GenerateID()
|
||||
if id == id2 {
|
||||
t.Error("expected unique IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []llm.Message
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, ""},
|
||||
{"no user msg", []llm.Message{{Role: "system", Content: "hi"}}, ""},
|
||||
{"short", []llm.Message{{Role: "user", Content: "hello world"}}, "hello world"},
|
||||
{"long", []llm.Message{{Role: "user", Content: "this is a really long message that should be truncated at some point in the middle"}}, "this is a really long message that..."},
|
||||
{"multiline", []llm.Message{{Role: "user", Content: "first line\nsecond line"}}, "first line"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DefaultName(tt.messages)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefaultName = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreCreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "sessions")
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
info, err := os.Stat(store.dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Error("expected directory")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user