feat(history): 会话消息持久化 + 主/次级会话(design/16)
- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
This commit is contained in:
1 parent
cc3eecacac
commit
48421923f6
15 files changed
+1305
-18
No files matched your search
@@ -0,0 +1,390 @@
|
||||
// Package history implements the persistent chat-history session model described in
|
||||
// DevDocs/design/16-会话持久化与主次级会话.md.
|
||||
//
|
||||
// It is the storage layer, not the orchestration/context layer: it persists the raw
|
||||
// message stream (chat_messages) and the persistent session records (chat_sessions)
|
||||
// so the Web frontend can request + display history, and secondary (sub) sessions can
|
||||
// be created, listed and deleted. It intentionally does not touch memory (design/16
|
||||
// §3.2: memory is persona-scoped and lives in runtime/memory), does not build context
|
||||
// (runtime/loop) and does not schedule turns (gateway/session_orchestrator).
|
||||
//
|
||||
// Session model (design/16 §2.1): there is exactly one fixed main session (id "web") and
|
||||
// any number of sub sessions (id "web:<uuid>"). Main + sub share the same persona and
|
||||
// therefore the same memory; this package only models sessions + messages.
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Session roles and types. These are the values persisted in the role / type columns.
|
||||
const (
|
||||
// RoleUser, RoleAssistant, RoleSystem classify a message's author.
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleSystem = "system"
|
||||
|
||||
// SessionMain and SessionSub classify a persistent session.
|
||||
SessionMain = "main"
|
||||
SessionSub = "sub"
|
||||
)
|
||||
|
||||
// MainSessionID is the fixed id of the one main session (design/16 §2.1). Sub session
|
||||
// ids are MainSessionID + ":" + uuid.
|
||||
const MainSessionID = "web"
|
||||
|
||||
// defaultPageLimit is the page size used when the caller does not pass one.
|
||||
const defaultPageLimit = 50
|
||||
|
||||
// maxPageLimit bounds a client-supplied limit so a single request cannot drain memory.
|
||||
const maxPageLimit = 200
|
||||
|
||||
// Sentinels distinguishing history package failures. Callers wrap them with %w so
|
||||
// errors.Is works through the chain.
|
||||
var (
|
||||
// ErrNilDB is returned when NewStore receives a nil *sql.DB.
|
||||
ErrNilDB = errors.New("history: nil db")
|
||||
|
||||
// ErrImmutableMain is returned when a caller tries to delete the fixed main session.
|
||||
ErrImmutableMain = errors.New("history: the main session cannot be deleted")
|
||||
)
|
||||
|
||||
// Message is one persisted chat message. CreatedAt is a Unix millisecond timestamp and
|
||||
// doubles as the pagination cursor (design/16 §3.1): pages are ordered newest-first and
|
||||
// "before" is the created_at of the oldest message on the previous page.
|
||||
type Message struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Role string
|
||||
Content string
|
||||
Mode string
|
||||
Mood string
|
||||
Image string
|
||||
Thinking string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta is the wire-facing metadata of one persistent session.
|
||||
type SessionMeta struct {
|
||||
ID string
|
||||
Type string // "main" | "sub"
|
||||
Title string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
// hold an interface rather than a concrete *Store.
|
||||
type HistoryStore interface {
|
||||
// Append persists msg, assigning an id and timestamp when the caller omitted them.
|
||||
Append(ctx context.Context, msg Message) error
|
||||
// List returns up to limit messages for sessionID, newest first. before is a
|
||||
// millisecond timestamp cursor: when nonzero, only messages older than before are
|
||||
// returned (inclusive-free, matching the "before" bound on the frontend).
|
||||
List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error)
|
||||
// Recent returns the most recent n messages for sessionID in oldest-first order,
|
||||
// for WS replay / context tail usage.
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]Message, error)
|
||||
// DeleteSession removes a sub session and all its messages. The main session is
|
||||
// immutable and returns ErrImmutableMain.
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
// ListSessions returns the main session first then every sub session, oldest first.
|
||||
ListSessions(ctx context.Context) ([]SessionMeta, error)
|
||||
}
|
||||
|
||||
// Store is the SQLite-backed chat-history store. It owns the chat_sessions and
|
||||
// chat_messages tables. Concurrency is delegated to *sql.DB (safe for concurrent use),
|
||||
// so Store holds no further lock. Every query is parameterized and reads list columns
|
||||
// explicitly (no SELECT *); reads take a context.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// rowScanner is satisfied by both *sql.Row and *sql.Rows, so a single scanMessage
|
||||
// helper serves List and Recent.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// NewStore builds a Store on db, ensuring the chat_sessions/chat_messages schema exists
|
||||
// and that the fixed main session row is present. The DDL mirrors the 0003 migration;
|
||||
// it is idempotent so the store is self-contained and usable from any *sql.DB, matching
|
||||
// the memory and session packages. db must be live; nil returns ErrNilDB. log may be nil.
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
log = logger.WithSource(discardLogger(log), "history")
|
||||
for _, ddl := range []string{
|
||||
createSessionsTable,
|
||||
createMessagesTable,
|
||||
createMessagesIndex,
|
||||
} {
|
||||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||||
return nil, fmt.Errorf("history: apply schema: %w", err)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db, log: log}
|
||||
if err := s.ensureMainSession(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Append persists msg, assigning a fresh id and a now-ms CreatedAt when the caller left
|
||||
// either empty/zero. It returns the error wrapped with the operation name.
|
||||
func (s *Store) Append(ctx context.Context, msg Message) error {
|
||||
if msg.ID == "" {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: append id: %w", err)
|
||||
}
|
||||
msg.ID = id
|
||||
}
|
||||
if msg.CreatedAt == 0 {
|
||||
msg.CreatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
||||
(id, session_id, role, content, mode, mood, image, thinking, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||
msg.Image, msg.Thinking, msg.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns up to limit messages for sessionID, newest first. When before is nonzero
|
||||
// it bounds the query to messages strictly older than before, so a client can page upward
|
||||
// by passing the oldest message's created_at from the previous page. The returned slice
|
||||
// is non-nil. limit is clamped to [1, maxPageLimit].
|
||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
|
||||
FROM chat_messages WHERE session_id = ?`
|
||||
args := []any{sessionID}
|
||||
if before > 0 {
|
||||
query += ` AND created_at < ?`
|
||||
args = append(args, before)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: list %s: %w", sessionID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
msgs := make([]Message, 0)
|
||||
for rows.Next() {
|
||||
m, err := scanMessage(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: scan list %s: %w", sessionID, err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("history: iterate list %s: %w", sessionID, err)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// Recent returns the most recent n messages for sessionID in oldest-first order (the
|
||||
// tail-most window), for WS replay / context tail. The returned slice is non-nil.
|
||||
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
|
||||
if n < 1 {
|
||||
n = defaultPageLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, created_at
|
||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
// Recent returns newest-last so the window is in chronological order; build it and
|
||||
// reverse in place.
|
||||
msgs := make([]Message, 0)
|
||||
for rows.Next() {
|
||||
m, err := scanMessage(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: scan recent %s: %w", sessionID, err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("history: iterate recent %s: %w", sessionID, err)
|
||||
}
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a sub session and all its messages in one transaction. The fixed
|
||||
// main session is immutable and returns ErrImmutableMain (design/16 §6).
|
||||
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == MainSessionID {
|
||||
return ErrImmutableMain
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: begin delete %s: %w", sessionID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = ?`, sessionID); err != nil {
|
||||
return fmt.Errorf("history: delete messages %s: %w", sessionID, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_sessions WHERE id = ? AND type = ?`, sessionID, SessionSub); err != nil {
|
||||
return fmt.Errorf("history: delete session %s: %w", sessionID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("history: commit delete %s: %w", sessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubSession creates a sub session and returns its id (MainSessionID:uuid). ownerID is
|
||||
// accepted for interface completeness (the Web owner is identified by JWT, not stored per
|
||||
// session); it is logged at debug and not persisted.
|
||||
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("history: new sub id: %w", err)
|
||||
}
|
||||
sessionID := MainSessionID + ":" + id
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, created_at)
|
||||
VALUES (?, ?, ?, ?)`, sessionID, SessionSub, "", now); err != nil {
|
||||
return "", fmt.Errorf("history: insert sub session: %w", err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("sub session created", "session_id", sessionID, "owner_id", ownerID)
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// ListSessions returns the main session first, then every sub session, oldest first. The
|
||||
// returned slice is non-nil.
|
||||
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, type, title, created_at FROM chat_sessions
|
||||
ORDER BY (type = ?) DESC, created_at ASC`, SessionMain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: list sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
sessions := make([]SessionMeta, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
meta SessionMeta
|
||||
typeStr string
|
||||
createdAt int64
|
||||
)
|
||||
if err := rows.Scan(&meta.ID, &typeStr, &meta.Title, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("history: scan session: %w", err)
|
||||
}
|
||||
meta.Type = typeStr
|
||||
meta.CreatedAt = createdAt
|
||||
sessions = append(sessions, meta)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("history: iterate sessions: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// ensureMainSession inserts the fixed main session row when it is absent, so ListSessions
|
||||
// always reports the main session even before any message is written.
|
||||
func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO chat_sessions (id, type, title, created_at)
|
||||
VALUES (?, ?, ?, ?)`, MainSessionID, SessionMain, "", time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: ensure main session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanMessage reads one chat_messages row. message columns are scanned positionally.
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, image, thinking string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &image, &thinking, &m.CreatedAt); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Image, m.Thinking = mode, mood, image, thinking
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// clampLimit bounds limit to [1, maxPageLimit]; a caller-supplied 0 or negative falls back
|
||||
// to defaultPageLimit.
|
||||
func clampLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
return defaultPageLimit
|
||||
}
|
||||
if limit > maxPageLimit {
|
||||
return maxPageLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a UUID
|
||||
// dependency so the package stays self-contained.
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("history: generate id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
|
||||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
if log != nil {
|
||||
return log
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// Schema DDL. The 0003 migration in internal/db is the authoritative mechanism for
|
||||
// domain tables, but it is intentionally also re-issued here idempotently so the store is
|
||||
// self-contained and usable from any *sql.DB (matching the memory and session packages).
|
||||
const createSessionsTable = `
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
const createMessagesTable = `
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
mode TEXT,
|
||||
mood TEXT,
|
||||
image TEXT,
|
||||
thinking TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
const createMessagesIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_msgs_session_created
|
||||
ON chat_messages (session_id, created_at DESC)`
|
||||
@@ -0,0 +1,204 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newStore opens an isolated in-memory DB and returns a Store on it.
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
st, err := NewStore(ctx, conn, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestNewStoreNilDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewStore(context.Background(), nil, testLogger()); !errors.Is(err, ErrNilDB) {
|
||||
t.Fatalf("err = %v, want wrap of ErrNilDB", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAndListNewestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i, ts := range []int64{1000, 2000, 3000} {
|
||||
m := Message{SessionID: MainSessionID, Role: RoleUser, Content: "hi", CreatedAt: ts}
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(msgs))
|
||||
}
|
||||
// Newest first.
|
||||
if msgs[0].CreatedAt != 3000 || msgs[1].CreatedAt != 2000 || msgs[2].CreatedAt != 1000 {
|
||||
t.Fatalf("order = %+v, want [3000 2000 1000]", []int64{msgs[0].CreatedAt, msgs[1].CreatedAt, msgs[2].CreatedAt})
|
||||
}
|
||||
// Id/timestamp assigned when the caller omits them.
|
||||
empty := Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "ok"}
|
||||
if err := st.Append(ctx, empty); err != nil {
|
||||
t.Fatalf("append no ts: %v", err)
|
||||
}
|
||||
msgs, err = st.List(ctx, MainSessionID, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list after append: %v", err)
|
||||
}
|
||||
if msgs[0].ID == "" || msgs[0].CreatedAt == 0 {
|
||||
t.Fatalf("append did not fill id/created_at: %+v", msgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPaginationBefore(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000, 6000} {
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
|
||||
t.Fatalf("append %d: %v", ts, err)
|
||||
}
|
||||
}
|
||||
|
||||
// First page: newest 2.
|
||||
page, err := st.List(ctx, MainSessionID, 0, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("first page: %v", err)
|
||||
}
|
||||
if len(page) != 2 || page[0].CreatedAt != 6000 || page[1].CreatedAt != 5000 {
|
||||
t.Fatalf("first page = %+v, want [6000 5000]", page)
|
||||
}
|
||||
// Cursor on the page's oldest (5000) → older page [4000 3000].
|
||||
next, err := st.List(ctx, MainSessionID, page[1].CreatedAt, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("next page: %v", err)
|
||||
}
|
||||
if len(next) != 2 || next[0].CreatedAt != 4000 || next[1].CreatedAt != 3000 {
|
||||
t.Fatalf("next page = %+v, want [4000 3000]", next)
|
||||
}
|
||||
// Past the start → empty.
|
||||
tail, err := st.List(ctx, MainSessionID, 1000, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("tail: %v", err)
|
||||
}
|
||||
if len(tail) != 0 {
|
||||
t.Fatalf("tail = %+v, want empty", tail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentChronological(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, ts := range []int64{1000, 2000, 3000, 4000, 5000} {
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "x", CreatedAt: ts}); err != nil {
|
||||
t.Fatalf("append %d: %v", ts, err)
|
||||
}
|
||||
}
|
||||
recent, err := st.Recent(ctx, MainSessionID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
if len(recent) != 3 || recent[0].CreatedAt != 3000 || recent[1].CreatedAt != 4000 || recent[2].CreatedAt != 5000 {
|
||||
t.Fatalf("recent = %+v, want [3000 4000 5000]", recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSubSessionAndListSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Main session exists by default.
|
||||
sessions, err := st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions initial: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
|
||||
t.Fatalf("initial sessions = %+v, want [main web]", sessions)
|
||||
}
|
||||
|
||||
id, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id, MainSessionID+":") {
|
||||
t.Fatalf("sub id = %q, want prefix web:", id)
|
||||
}
|
||||
|
||||
sessions, err = st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 2 || sessions[0].ID != MainSessionID || sessions[0].Type != SessionMain {
|
||||
t.Fatalf("sessions[0] = %+v, want main web first", sessions[0])
|
||||
}
|
||||
if sessions[1].ID != id || sessions[1].Type != SessionSub {
|
||||
t.Fatalf("sessions[1] = %+v, want sub %s", sessions[1], id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: "x", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
if err := st.DeleteSession(ctx, sub); err != nil {
|
||||
t.Fatalf("delete sub: %v", err)
|
||||
}
|
||||
msgs, err := st.List(ctx, sub, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list after delete: %v", err)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("messages after delete = %+v, want empty", msgs)
|
||||
}
|
||||
sessions, err := st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions after delete: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
|
||||
}
|
||||
|
||||
// Main session is immutable.
|
||||
if err := st.DeleteSession(ctx, MainSessionID); !errors.Is(err, ErrImmutableMain) {
|
||||
t.Fatalf("delete main err = %v, want ErrImmutableMain", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user