v0.0.4: add sessions and refine tui

This commit is contained in:
loveuer
2026-06-23 17:49:36 +08:00
parent 8dbcb1147f
commit e4c75c7c0e
12 changed files with 1333 additions and 110 deletions
+165 -8
View File
@@ -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] + "..."
}
+176 -4
View File
@@ -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())
}
}
+173
View File
@@ -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 ""
}
+170
View File
@@ -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")
}
}