diff --git a/go.mod b/go.mod index e97e379..2b59647 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ module github.com/crypto/openclaw -go 1.25 +go 1.26.0 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.61.0 +) + +require ( + github.com/dustin/go-humanize v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.49.0 // indirect + modernc.org/libc v1.79.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum index 4bc0337..406773a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,54 @@ +github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= +github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= +github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= +github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys= +modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4= +modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o= +modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8= +modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY= +modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..d6ab4d2 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,121 @@ +// Package agent implements the LLM tool-calling loop. +package agent + +import ( + "context" + "fmt" + "sync" + + "github.com/crypto/openclaw/internal/llm" + "github.com/crypto/openclaw/internal/store" + "github.com/crypto/openclaw/internal/tools" +) + +// Event is streamed to clients while the agent works. +type Event struct { + Type string `json:"type"` // delta | tool_call | tool_result | done | error + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + Args string `json:"args,omitempty"` + CallID string `json:"call_id,omitempty"` + Session string `json:"session_id,omitempty"` +} + +type Agent struct { + LLM llm.Provider + Tools *tools.Registry + Store *store.Store + SystemPrompt string + MaxIterations int + HistoryLimit int + + mu sync.Mutex + locks map[string]*sync.Mutex +} + +func (a *Agent) sessionLock(id string) *sync.Mutex { + a.mu.Lock() + defer a.mu.Unlock() + if a.locks == nil { + a.locks = map[string]*sync.Mutex{} + } + l, ok := a.locks[id] + if !ok { + l = &sync.Mutex{} + a.locks[id] = l + } + return l +} + +// Run appends the user message to the session and loops until the model +// answers without tool calls. Returns the final assistant text. +func (a *Agent) Run(ctx context.Context, sessionID, userText string, emit func(Event)) (string, error) { + if emit == nil { + emit = func(Event) {} + } + l := a.sessionLock(sessionID) + l.Lock() + defer l.Unlock() + + if _, err := a.Store.AppendMessage(ctx, sessionID, llm.Message{Role: llm.RoleUser, Content: userText}); err != nil { + return "", err + } + var specs []llm.ToolSpec + if a.Tools != nil { + specs = a.Tools.Specs() + } + for i := 0; i < a.MaxIterations; i++ { + msgs, err := a.history(ctx, sessionID) + if err != nil { + return "", err + } + resp, err := a.LLM.Chat(ctx, llm.Request{Messages: msgs, Tools: specs}, func(t string) { + emit(Event{Type: "delta", Text: t}) + }) + if err != nil { + return "", err + } + asst := llm.Message{Role: llm.RoleAssistant, Content: resp.Content, ToolCalls: resp.ToolCalls} + if _, err := a.Store.AppendMessage(ctx, sessionID, asst); err != nil { + return "", err + } + if len(resp.ToolCalls) == 0 { + return resp.Content, nil + } + for _, tc := range resp.ToolCalls { + emit(Event{Type: "tool_call", Tool: tc.Name, Args: tc.Arguments, CallID: tc.ID}) + out := a.Tools.Call(ctx, tc.Name, tc.Arguments) + emit(Event{Type: "tool_result", Tool: tc.Name, Text: clip(out, 2000), CallID: tc.ID}) + if _, err := a.Store.AppendMessage(ctx, sessionID, llm.Message{Role: llm.RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: out}); err != nil { + return "", err + } + } + } + return "", fmt.Errorf("stopped after %d iterations without a final answer", a.MaxIterations) +} + +func (a *Agent) history(ctx context.Context, sessionID string) ([]llm.Message, error) { + stored, err := a.Store.Messages(ctx, sessionID, a.HistoryLimit) + if err != nil { + return nil, err + } + // A truncated window must not start with orphaned tool results. + for len(stored) > 0 && stored[0].Role == llm.RoleTool { + stored = stored[1:] + } + msgs := make([]llm.Message, 0, len(stored)+1) + if a.SystemPrompt != "" { + msgs = append(msgs, llm.Message{Role: llm.RoleSystem, Content: a.SystemPrompt}) + } + for _, m := range stored { + msgs = append(msgs, m.Message) + } + return msgs, nil +} + +func clip(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..94a76a6 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,93 @@ +package agent + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crypto/openclaw/internal/llm" + "github.com/crypto/openclaw/internal/store" + "github.com/crypto/openclaw/internal/tools" +) + +// scripted provider returns queued responses and records requests. +type scripted struct { + resps []llm.Response + reqs []llm.Request +} + +func (s *scripted) Chat(_ context.Context, req llm.Request, d llm.DeltaFunc) (llm.Response, error) { + s.reqs = append(s.reqs, req) + r := s.resps[0] + s.resps = s.resps[1:] + if d != nil && r.Content != "" { + d(r.Content) + } + return r, nil +} + +func newStore(t *testing.T) *store.Store { + s, err := store.Open(filepath.Join(t.TempDir(), "a.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestToolLoop(t *testing.T) { + st := newStore(t) + ss, _ := st.CreateSession(context.Background(), "") + p := &scripted{resps: []llm.Response{ + {ToolCalls: []llm.ToolCall{{ID: "c1", Name: "time_now", Arguments: `{"timezone":"UTC"}`}}}, + {Content: "It is noon."}, + }} + fixed := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + a := &Agent{LLM: p, Store: st, Tools: tools.NewRegistry(tools.TimeNow{Now: func() time.Time { return fixed }}), SystemPrompt: "sys", MaxIterations: 4} + var events []Event + out, err := a.Run(context.Background(), ss.ID, "what time?", func(e Event) { events = append(events, e) }) + if err != nil || out != "It is noon." { + t.Fatalf("%q %v", out, err) + } + second := p.reqs[1].Messages + if second[0].Role != llm.RoleSystem || second[len(second)-1].Role != llm.RoleTool || !strings.HasPrefix(second[len(second)-1].Content, "2026-05-05T12:00:00") { + t.Fatalf("bad second request: %+v", second) + } + types := []string{} + for _, e := range events { + types = append(types, e.Type) + } + if strings.Join(types, ",") != "tool_call,tool_result,delta" { + t.Fatalf("events %v", types) + } + msgs, _ := st.Messages(context.Background(), ss.ID, 0) + if len(msgs) != 4 { + t.Fatalf("stored %d msgs", len(msgs)) + } +} + +func TestMaxIterations(t *testing.T) { + st := newStore(t) + ss, _ := st.CreateSession(context.Background(), "") + call := llm.Response{ToolCalls: []llm.ToolCall{{ID: "c", Name: "time_now", Arguments: "{}"}}} + a := &Agent{LLM: &scripted{resps: []llm.Response{call, call}}, Store: st, Tools: tools.NewRegistry(tools.TimeNow{}), MaxIterations: 2} + if _, err := a.Run(context.Background(), ss.ID, "loop", nil); err == nil { + t.Fatal("expected iteration error") + } +} + +func TestHistoryDropsOrphanToolMessages(t *testing.T) { + st := newStore(t) + ctx := context.Background() + ss, _ := st.CreateSession(ctx, "") + st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "x", Name: "t"}}}) + st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleTool, ToolCallID: "x", Content: "r"}) + st.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleAssistant, Content: "ok"}) + a := &Agent{Store: st, HistoryLimit: 2} + h, _ := a.history(ctx, ss.ID) + if len(h) != 1 || h[0].Content != "ok" { + t.Fatalf("%+v", h) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..49e667a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,181 @@ +// Package store persists sessions and messages in SQLite (pure Go driver, no CGO). +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/crypto/openclaw/internal/llm" + _ "modernc.org/sqlite" +) + +var ErrNotFound = errors.New("not found") + +type Session struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type StoredMessage struct { + llm.Message + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` +} + +type Store struct{ db *sql.DB } + +const schema = ` +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS sessions( + id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS messages( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + tool_calls TEXT NOT NULL DEFAULT '', tool_call_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL); +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id); +` + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // SQLite: serialize writers, avoids SQLITE_BUSY + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func newID() string { + b := make([]byte, 12) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) { + now := time.Now().UTC() + ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now} + _, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`, + ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli()) + return ss, err +} + +func (s *Store) GetSession(ctx context.Context, id string) (Session, error) { + var ss Session + var c, u int64 + err := s.db.QueryRowContext(ctx, `SELECT id,title,created_at,updated_at FROM sessions WHERE id=?`, id).Scan(&ss.ID, &ss.Title, &c, &u) + if errors.Is(err, sql.ErrNoRows) { + return ss, ErrNotFound + } + ss.CreatedAt, ss.UpdatedAt = time.UnixMilli(c).UTC(), time.UnixMilli(u).UTC() + return ss, err +} + +func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,title,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Session{} + for rows.Next() { + var ss Session + var c, u int64 + if err := rows.Scan(&ss.ID, &ss.Title, &c, &u); err != nil { + return nil, err + } + ss.CreatedAt, ss.UpdatedAt = time.UnixMilli(c).UTC(), time.UnixMilli(u).UTC() + out = append(out, ss) + } + return out, rows.Err() +} + +func (s *Store) RenameSession(ctx context.Context, id, title string) error { + res, err := s.db.ExecContext(ctx, `UPDATE sessions SET title=? WHERE id=?`, title, id) + return affected(res, err) +} + +func (s *Store) DeleteSession(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id=?`, id) + return affected(res, err) +} + +func affected(res sql.Result, err error) error { + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) AppendMessage(ctx context.Context, sessionID string, m llm.Message) (int64, error) { + tc := "" + if len(m.ToolCalls) > 0 { + b, _ := json.Marshal(m.ToolCalls) + tc = string(b) + } + now := time.Now().UTC().UnixMilli() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, `INSERT INTO messages(session_id,role,content,tool_calls,tool_call_id,name,created_at) VALUES(?,?,?,?,?,?,?)`, + sessionID, string(m.Role), m.Content, tc, m.ToolCallID, m.Name, now) + if err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `UPDATE sessions SET updated_at=? WHERE id=?`, now, sessionID); err != nil { + return 0, err + } + id, _ := res.LastInsertId() + return id, tx.Commit() +} + +// Messages returns the last `limit` messages of a session in chronological order (limit<=0: all). +func (s *Store) Messages(ctx context.Context, sessionID string, limit int) ([]StoredMessage, error) { + if limit <= 0 { + limit = -1 + } + rows, err := s.db.QueryContext(ctx, `SELECT * FROM (SELECT id,role,content,tool_calls,tool_call_id,name,created_at + FROM messages WHERE session_id=? ORDER BY id DESC LIMIT ?) ORDER BY id ASC`, sessionID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []StoredMessage{} + for rows.Next() { + var m StoredMessage + var role, tc string + var c int64 + if err := rows.Scan(&m.ID, &role, &m.Content, &tc, &m.ToolCallID, &m.Name, &c); err != nil { + return nil, err + } + m.Role, m.CreatedAt = llm.Role(role), time.UnixMilli(c).UTC() + if tc != "" { + if err := json.Unmarshal([]byte(tc), &m.ToolCalls); err != nil { + return nil, err + } + } + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..c669536 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/crypto/openclaw/internal/llm" +) + +func TestStoreRoundTrip(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + ss, err := s.CreateSession(ctx, "first") + if err != nil { + t.Fatal(err) + } + s.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleUser, Content: "hi"}) + s.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "time_now", Arguments: "{}"}}}) + s.AppendMessage(ctx, ss.ID, llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "time_now", Content: "now"}) + all, _ := s.Messages(ctx, ss.ID, 0) + if len(all) != 3 || all[1].ToolCalls[0].Name != "time_now" || all[2].ToolCallID != "c1" { + t.Fatalf("%+v", all) + } + last, _ := s.Messages(ctx, ss.ID, 2) + if len(last) != 2 || last[0].Role != llm.RoleAssistant { + t.Fatalf("limit: %+v", last) + } + if err := s.RenameSession(ctx, ss.ID, "renamed"); err != nil { + t.Fatal(err) + } + list, _ := s.ListSessions(ctx, 10) + if len(list) != 1 || list[0].Title != "renamed" { + t.Fatalf("%+v", list) + } + if err := s.DeleteSession(ctx, ss.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetSession(ctx, ss.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if m, _ := s.Messages(ctx, ss.ID, 0); len(m) != 0 { + t.Fatal("cascade delete failed") + } +}